//go:build integration package integration_test import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // Tier 2 Block C — multi-host / collaborators. // TestCollaboratorBackfill confirms the migration creates an `owner` row in // event_collaborators for every legacy event whose host_id pointed at a user. // Existing single-host events must keep working after Block C lands. func TestCollaboratorBackfill(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, host, token := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, token, "Backfill Test", "backfill-test") var role string must(t, db.Pool.QueryRow(ctx, `SELECT role FROM event_collaborators WHERE event_id = $1 AND user_id = $2`, eventID, uuid.UUID(host), ).Scan(&role), "load backfilled role") if role != "owner" { t.Fatalf("backfilled role: got %q, want owner", role) } } // TestCollaboratorRoleEnforcement exercises every endpoint at every role // boundary. This is the big regression net for the authz refactor — if any // handler dropped its requireRole call this test will catch it. func TestCollaboratorRoleEnforcement(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Roles Test", "roles-test") // Spin up two more verified users we can promote to editor / viewer. editor, editorToken := makeAuthedUser(t, ctx, db.Pool) viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool) directlyInsertCollaborator(t, ctx, db.Pool, eventID, editor, "editor", uuid.UUID(ownerID)) directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID)) guestID := createGuest(t, srv.URL, ownerToken, eventID, "Test Guest") t.Run("viewer can read but not write", func(t *testing.T) { // Read endpoints — 200. assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventID), viewerToken, nil, http.StatusOK) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), viewerToken, nil, http.StatusOK) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/activity", srv.URL, eventID), viewerToken, nil, http.StatusOK) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID), viewerToken, nil, http.StatusOK) // Write endpoints — 403. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), viewerToken, map[string]any{"name": "Should fail"}, http.StatusForbidden) assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventID), viewerToken, map[string]any{"venue": "Other"}, http.StatusForbidden) assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), viewerToken, nil, http.StatusForbidden) // Owner-only — 403 for both editor and viewer. assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventID), viewerToken, nil, http.StatusForbidden) assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID), viewerToken, map[string]any{"email": "x@x.com", "role": "viewer"}, http.StatusForbidden) }) t.Run("editor can write guests + patch event but not delete or manage team", func(t *testing.T) { // Editor adds a guest. var g struct{ ID uuid.UUID `json:"id"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), editorToken, map[string]any{"name": "Editor's guest"}, http.StatusCreated, &g) // Editor patches the event. assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventID), editorToken, map[string]any{"venue": "Editor's Hall"}, http.StatusOK) // Editor cannot delete the event. assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventID), editorToken, nil, http.StatusForbidden) // Editor cannot invite collaborators. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID), editorToken, map[string]any{"email": "x@x.com", "role": "viewer"}, http.StatusForbidden) }) t.Run("non-member gets 404 even for read endpoints", func(t *testing.T) { _, outsiderToken := makeAuthedUser(t, ctx, db.Pool) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventID), outsiderToken, nil, http.StatusNotFound) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), outsiderToken, nil, http.StatusNotFound) }) t.Run("GET /events lists shared events for collaborators", func(t *testing.T) { var list struct { Events []struct { ID uuid.UUID `json:"id"` } `json:"events"` } getJSONAuthed(t, srv.URL+"/events", editorToken, http.StatusOK, &list) found := false for _, e := range list.Events { if e.ID == eventID { found = true break } } if !found { t.Fatalf("editor's /events did not include the shared event: %+v", list) } }) } // TestCollaboratorRemoveLastOwner covers the "you can't orphan the event" // guarantee. Demote-then-remove must work for non-last owners; refusing on // the last is mandatory. func TestCollaboratorRemoveLastOwner(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Last Owner", "last-owner") // Cannot remove yourself when you're the only owner. assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/collaborators/%s", srv.URL, eventID, uuid.UUID(ownerID)), ownerToken, nil, http.StatusBadRequest) // Promote a second user to owner; now removing the first is allowed. second, _ := makeAuthedUser(t, ctx, db.Pool) directlyInsertCollaborator(t, ctx, db.Pool, eventID, second, "owner", uuid.UUID(ownerID)) assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/collaborators/%s", srv.URL, eventID, uuid.UUID(ownerID)), ownerToken, nil, http.StatusNoContent) } // TestCollaboratorInviteFlow walks the happy path: owner creates an invite, // the invitee accepts, they show up as a collaborator. Then re-using the // same invite token returns 410 Gone. func TestCollaboratorInviteFlow(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, _, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Invite Flow", "invite-flow") // Make the invitee's account ahead of time so we can sign them in. inviteeEmail := fmt.Sprintf("invitee-%d@guestguard.test", time.Now().UnixNano()) inviteeID := insertVerifiedUser(t, ctx, db.Pool, inviteeEmail, "Invitee") inviteeToken := issueHostToken(t, inviteeID) // Owner invites the user as editor. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID), ownerToken, map[string]any{"email": inviteeEmail, "role": "editor"}, http.StatusCreated) // Pull the raw token out of the DB the way the email link would have. // We don't surface it via the API (security), so reach into the table. var tokenHash string must(t, db.Pool.QueryRow(ctx, ` SELECT token_hash FROM collaborator_invites WHERE event_id = $1 AND lower(email) = lower($2) AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1 `, eventID, inviteeEmail).Scan(&tokenHash), "load invite hash") // To get the raw token from the hash we can't reverse SHA256; so for // tests we generate our own invite via the DB and use that raw value // instead. Cleaner: replace the row with a known-token invite. rawToken := fmt.Sprintf("test-raw-token-%d", time.Now().UnixNano()) sum := sha256.Sum256([]byte(rawToken)) knownHash := hex.EncodeToString(sum[:]) _, err := db.Pool.Exec(ctx, ` UPDATE collaborator_invites SET token_hash = $1 WHERE token_hash = $2 `, knownHash, tokenHash) must(t, err, "rewrite invite token_hash") // Preview the invite (unauthed) — confirms the summary endpoint works. previewResp, err := http.Get(srv.URL + "/invites/" + rawToken) must(t, err, "preview invite") previewResp.Body.Close() if previewResp.StatusCode != http.StatusOK { t.Fatalf("preview status: %d", previewResp.StatusCode) } // Accept (authed as invitee). Single-use; first call → 200. assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept", inviteeToken, nil, http.StatusOK) // Now the invitee has an editor role — they can list guests. assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), inviteeToken, nil, http.StatusOK) // Replay the same token — already consumed → 410. assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept", inviteeToken, nil, http.StatusGone) } // TestCollaboratorInviteEmailMismatch ensures a leaked invite link can't be // accepted by a different account. func TestCollaboratorInviteEmailMismatch(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, _, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Email Mismatch", "email-mismatch") intendedEmail := fmt.Sprintf("intended-%d@guestguard.test", time.Now().UnixNano()) assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/collaborators", srv.URL, eventID), ownerToken, map[string]any{"email": intendedEmail, "role": "editor"}, http.StatusCreated) // Replace with a known token, as in the happy-path test. rawToken := fmt.Sprintf("test-mismatch-token-%d", time.Now().UnixNano()) sum := sha256.Sum256([]byte(rawToken)) hash := hex.EncodeToString(sum[:]) _, err := db.Pool.Exec(ctx, ` UPDATE collaborator_invites SET token_hash = $1 WHERE event_id = $2 AND lower(email) = lower($3) AND consumed_at IS NULL `, hash, eventID, intendedEmail) must(t, err, "rewrite invite") // A different user tries to accept the link. otherID, otherToken := makeAuthedUser(t, ctx, db.Pool) _ = otherID assertStatus(t, http.MethodPost, srv.URL+"/invites/"+rawToken+"/accept", otherToken, nil, http.StatusForbidden) } // TestCollaboratorInviteExpired tests that an old invite returns 410. func TestCollaboratorInviteExpired(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Expired", "expired") // Insert an already-expired invite directly so we don't have to wait // seven days during the test. rawToken := fmt.Sprintf("test-expired-%d", time.Now().UnixNano()) sum := sha256.Sum256([]byte(rawToken)) hash := hex.EncodeToString(sum[:]) _, err := db.Pool.Exec(ctx, ` INSERT INTO collaborator_invites (token_hash, event_id, email, role, invited_by, expires_at) VALUES ($1, $2, 'doesnt@matter.test', 'editor', $3, now() - interval '1 hour') `, hash, eventID, uuid.UUID(ownerID)) must(t, err, "insert expired invite") resp, err := http.Get(srv.URL + "/invites/" + rawToken) must(t, err, "get expired preview") resp.Body.Close() if resp.StatusCode != http.StatusGone { t.Fatalf("expected 410 Gone, got %d", resp.StatusCode) } } // --- helpers used across collaborator tests --- // makeAuthedUser creates a verified user with a Business subscription and // returns the id + a bearer token signed with the test JWT secret. func makeAuthedUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) (uuid.UUID, string) { t.Helper() email := fmt.Sprintf("collab-%d@guestguard.test", time.Now().UnixNano()) id := insertVerifiedUser(t, ctx, pool, email, "Collab User") grantBusinessTier(t, ctx, pool, id) return id, issueHostToken(t, id) } // insertVerifiedUser creates a user row directly, bypassing signup/verify // so tests stay fast. func insertVerifiedUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email, name string) uuid.UUID { t.Helper() var id uuid.UUID must(t, pool.QueryRow(ctx, ` INSERT INTO users (email, name, email_verified, email_verified_at) VALUES ($1, $2, TRUE, now()) RETURNING id `, strings.ToLower(email), name).Scan(&id), "insert verified user") return id } func directlyInsertCollaborator(t *testing.T, ctx context.Context, pool *pgxpool.Pool, eventID, userID uuid.UUID, role string, invitedBy uuid.UUID) { t.Helper() _, err := pool.Exec(ctx, ` INSERT INTO event_collaborators (event_id, user_id, role, invited_by, invited_at, accepted_at) VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (event_id, user_id) DO UPDATE SET role = EXCLUDED.role `, eventID, userID, role, invitedBy) must(t, err, "insert collaborator") }