feat(tier2): multi-host / collaborators — Block C

Events can now have multiple users with distinct roles:
  owner   — manage collaborators, delete event, full access
  editor  — manage guests, tokens, CSV import, patch event
  viewer  — read-only access to everything

Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
  no future event can exist without one

Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
  insufficient role 403. Replaces requireEventOwner across every
  shared-role handler (events.get/update, guests CRUD, tokens issue/
  rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching

Collaborator endpoints
- GET    /events/{id}/collaborators           (viewer+)
- POST   /events/{id}/collaborators           (owner)  — sends invite email
- PATCH  /events/{id}/collaborators/{user_id} (owner)  — role change
- DELETE /events/{id}/collaborators/{user_id} (owner)  — refuses last owner
- DELETE /events/{id}/collaborators/pending   (owner)  — cancel invite
- GET    /invites/{token}                     (public) — preview summary
- POST   /invites/{token}/accept              (authed) — atomic accept

Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
  senders + log stub; collaborator_invite.html/txt branded template

Frontend
- TeamCard.vue on the event detail page: lists collaborators with
  inline role-change + remove, pending-invites with cancel, invite
  modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
  or sign-in with pre-filled email, refuses mismatched accounts

Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
  delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 22:14:50 +01:00
parent 6803d700b4
commit 3973e4058d
28 changed files with 2108 additions and 32 deletions
+328
View File
@@ -0,0 +1,328 @@
//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")
}