//go:build integration package integration_test import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "testing" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/storage" ) // TestCrossTenantIsolation confirms that one authenticated host cannot // read, modify, or extend another host's event. All cross-tenant attempts // should return 404 — never 403 — so a probe can't tell whether a given // UUID exists at all. func TestCrossTenantIsolation(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, TokenTTL: 24 * time.Hour, JWTSecret: testJWTSecret, JWTIssuer: testJWTIssuer, AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 24 * time.Hour, EmailVerificationTTL: 1 * time.Hour, PasswordResetTTL: 1 * time.Hour, PublicBaseURL: "http://localhost", }) must(t, err, "build api server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) hostA := insertHost(t, ctx, db.Pool) hostB := insertHost(t, ctx, db.Pool) tokenA := issueHostToken(t, hostA) tokenB := issueHostToken(t, hostB) eventA := createEvent(t, srv.URL, tokenA, "Host A's Event", "host-a-event") t.Run("list returns only own events", func(t *testing.T) { out := struct { Events []struct { ID uuid.UUID `json:"id"` } `json:"events"` }{} getJSONAuthed(t, srv.URL+"/events", tokenB, http.StatusOK, &out) for _, e := range out.Events { if e.ID == eventA { t.Fatalf("host B saw host A's event in /events list") } } }) t.Run("GET other host's event is 404", func(t *testing.T) { assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventA), tokenB, nil, http.StatusNotFound) }) t.Run("PATCH other host's event is 404", func(t *testing.T) { body := map[string]any{"name": "hijacked"} assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventA), tokenB, body, http.StatusNotFound) }) t.Run("DELETE other host's event is 404", func(t *testing.T) { assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventA), tokenB, nil, http.StatusNotFound) }) t.Run("POST guest on other host's event is 404", func(t *testing.T) { body := map[string]any{"name": "Mallory"} assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventA), tokenB, body, http.StatusNotFound) }) t.Run("GET guests on other host's event is 404", func(t *testing.T) { assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventA), tokenB, nil, http.StatusNotFound) }) t.Run("GET activity on other host's event is 404", func(t *testing.T) { assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/activity", srv.URL, eventA), tokenB, nil, http.StatusNotFound) }) t.Run("no bearer is 401", func(t *testing.T) { assertStatus(t, http.MethodGet, srv.URL+"/events", "", nil, http.StatusUnauthorized) }) t.Run("ws-ticket for other host's event is 404", func(t *testing.T) { body := map[string]any{"event_id": eventA.String()} assertStatus(t, http.MethodPost, srv.URL+"/auth/ws-ticket", tokenB, body, http.StatusNotFound) }) t.Run("ws-ticket then WS handshake requires matching path", func(t *testing.T) { // Host A mints a ticket for their own event. var ticketResp struct { Ticket string `json:"ticket"` } postJSONAuthed(t, srv.URL+"/auth/ws-ticket", tokenA, map[string]any{"event_id": eventA.String()}, http.StatusOK, &ticketResp) if ticketResp.Ticket == "" { t.Fatal("empty ticket") } // Trying to use that ticket on a *different* event id must fail. bogus := uuid.New() req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, bogus, ticketResp.Ticket), nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") resp, err := http.DefaultClient.Do(req) must(t, err, "WS handshake") resp.Body.Close() if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 403 or 401, got %d", resp.StatusCode) } }) t.Run("replaying a consumed ticket fails", func(t *testing.T) { var ticketResp struct { Ticket string `json:"ticket"` } postJSONAuthed(t, srv.URL+"/auth/ws-ticket", tokenA, map[string]any{"event_id": eventA.String()}, http.StatusOK, &ticketResp) // First handshake against the correct event id — consumes the ticket. req1, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, eventA, ticketResp.Ticket), nil) req1.Header.Set("Upgrade", "websocket") req1.Header.Set("Connection", "Upgrade") resp1, err := http.DefaultClient.Do(req1) must(t, err, "WS handshake 1") resp1.Body.Close() // Replay — ticket is already consumed. req2, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, eventA, ticketResp.Ticket), nil) req2.Header.Set("Upgrade", "websocket") req2.Header.Set("Connection", "Upgrade") resp2, err := http.DefaultClient.Do(req2) must(t, err, "WS handshake 2") resp2.Body.Close() if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 on replay, got %d", resp2.StatusCode) } }) } func getJSONAuthed(t *testing.T, url, bearer string, wantStatus int, out any) { t.Helper() req, err := http.NewRequest(http.MethodGet, url, nil) must(t, err, "build get") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) must(t, err, "do get "+url) defer resp.Body.Close() if resp.StatusCode != wantStatus { body, _ := io.ReadAll(resp.Body) t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body) } if out != nil { must(t, json.NewDecoder(resp.Body).Decode(out), "decode response from "+url) } } func assertStatus(t *testing.T, method, url, bearer string, body any, wantStatus int) { t.Helper() var rdr io.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } req, err := http.NewRequest(method, url, rdr) must(t, err, "build "+method+" "+url) if rdr != nil { req.Header.Set("Content-Type", "application/json") } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) must(t, err, "do "+method+" "+url) defer resp.Body.Close() if resp.StatusCode != wantStatus { b, _ := io.ReadAll(resp.Body) t.Fatalf("%s %s status=%d want=%d body=%s", method, url, resp.StatusCode, wantStatus, b) } }