Files
苗大 ad6b42f093 fix(invitation): drop stale pending-invitation rows when an invite concludes elsewhere (MUL-7389)
* fix(server): make invitation accept/decline idempotent once concluded

Installed clients can hold a stale pending-invitation row for an
invitation that was concluded from another surface (web invite page,
another device). Both buttons then hit 400 "invitation is not
pending", which clients swallow silently, so the row survives until
restart.

- AcceptInvitation: re-accepting an already-accepted invitation is
  idempotent while the membership from the first accept still exists —
  return the existing member (200). Only pgx.ErrNoRows means the
  membership is gone (leaving the workspace was explicit and a stale
  retry must not undo it); any other lookup failure surfaces as 500
  instead of falling through to the 400 clients swallow, matching the
  JoinByShareLink pre-check.
- DeclineInvitation: declining a concluded invitation is a no-op
  (204) — the invitation is over either way, and the client only needs
  its refetch to drop the row.

This heals already-shipped clients with no client update.

* fix(server): deliver invitation conclusions to the invitee directly

The invitation:accepted / invitation:declined broadcasts only reach
clients whose socket is bound to the target workspace room. An invitee
usually isn't in that room yet — the client binds to the workspace it
currently has open, and a user with no workspace open has no room at
all — so their pending list never hears the conclusion and the stale
row survives until restart (#8432).

Keep the workspace broadcast (admin pending lists depend on it) and
additionally SendToUser the acting invitee with excludeWorkspace so
clients already in the room don't receive the event twice, mirroring
the member:added pattern. The actor is the invitee on every producer
path for both events; invitation:revoked keeps its invitee_user_id
routing because its actor is the revoking admin.

* fix(server): settle pending email invitation on share-link join

A user invited by email who joins through the share link instead
leaves that invitation row pending forever: it keeps its seat
reservation, keeps showing in their pending list, and a later Accept
on it dies on the CreateMember 409 — the second variant of #8432.

Settle the matching pending invitation inside the join transaction.
GetPendingInvitationByEmail plus the idx_invitation_unique_pending
partial index guarantee at most one pending row per (workspace,
email); AcceptInvitation re-checks status='pending' under the row
lock, so a concurrent decline just leaves nothing to settle.

- The share link's role wins: the user chose this entry point, so the
  member is created with link.Role and the invitation row is only
  settled, its own role left untouched.
- The join's own confirmed capacity claim covers the new seat, so the
  settled invitation's reservation is released (enqueue + best-effort
  immediate release, outbox worker as fallback) instead of charging
  the workspace twice for one joining user. Unmanaged mode keeps no
  durable reservation and resolves the release as a no-op.
- The settle publishes invitation:accepted with the same shape as the
  accept path, so workspace admins drop the row from their pending
  list instead of waiting on a refetch that never comes.

* fix(invitation): refresh the pending-invitations list when an invite concludes elsewhere

The sidebar's pending-invitations list never recovers on its own when
the invitation is concluded from another surface or device:

- Global QueryClient defaults are staleTime: Infinity with focus
  refetch off, and myInvitationListOptions overrides nothing, so the
  list only moves when something invalidates it.
- The invitation:accepted / invitation:declined WS handlers invalidated
  the workspace-scoped admin lists but not the account-level pending
  list (invitation:revoked already did).
- The sidebar's accept/decline mutations only handled onSuccess, so a
  "invitation is not pending" failure left the stale row on screen
  with no feedback.

- use-realtime-sync: invalidate workspaceKeys.myInvitations() on
  invitation:accepted and invitation:declined, matching
  invitation:revoked — gated on the acting user, since the workspace
  broadcast reaches every online member and an ungated invalidation
  refetches the account-level list once per member on every
  accept/decline. The actor rides the frame envelope: ws-client hands
  it to handlers as the second argument (wire frame shape is
  {type, payload, actor_id, actor_type}), so the gate compares that
  argument, not a payload field. The workspace-scoped admin lists
  still refresh unconditionally off the broadcast.
- app-sidebar: accept invalidates on onError and decline moves to
  onSettled, so any outcome refreshes the list.
- use-realtime-sync-invitations: cover the actor case and the
  non-actor negative case at the handler layer, plus a wire-contract
  case per event that drives a real WSClient through a fake WebSocket
  with the exact frames listeners.go marshals, pinning frame → unpack
  → handler → gate end to end.

The stale row now drops within a WS tick on updated clients; the
server-side direct delivery covers clients that never update.
2026-09-16 15:50:26 +08:00

888 lines
32 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/seatcapacity"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
var testSeq atomic.Int64
type shareJoinCapacityStub struct {
mu sync.Mutex
claimTokens []uuid.UUID
releaseTokens []uuid.UUID
claimGate *sync.WaitGroup
claimDecision *seatcapacity.Decision
releaseDecision *seatcapacity.Decision
}
func (*shareJoinCapacityStub) RecoveryAvailable() bool { return true }
func (s *shareJoinCapacityStub) ReserveInvitation(context.Context, uuid.UUID, uuid.UUID, time.Time) (seatcapacity.Decision, error) {
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
}
func (s *shareJoinCapacityStub) ClaimShareJoin(_ context.Context, _ uuid.UUID, token uuid.UUID) (seatcapacity.Decision, error) {
s.mu.Lock()
s.claimTokens = append(s.claimTokens, token)
s.mu.Unlock()
if s.claimGate != nil {
s.claimGate.Done()
s.claimGate.Wait()
}
decision := seatcapacity.Decision{Managed: true, Allowed: true}
if s.claimDecision != nil {
decision = *s.claimDecision
}
return decision, nil
}
func (s *shareJoinCapacityStub) Consume(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
}
func (s *shareJoinCapacityStub) Confirm(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
}
func (s *shareJoinCapacityStub) Release(_ context.Context, _ uuid.UUID, token uuid.UUID) (seatcapacity.Decision, error) {
s.mu.Lock()
s.releaseTokens = append(s.releaseTokens, token)
decision := seatcapacity.Decision{Managed: true, Allowed: true}
if s.releaseDecision != nil {
decision = *s.releaseDecision
}
s.mu.Unlock()
return decision, nil
}
func (s *shareJoinCapacityStub) ReleaseMember(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
}
func (s *shareJoinCapacityStub) GetOperation(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
return seatcapacity.Decision{}, nil
}
func (s *shareJoinCapacityStub) tokens() []uuid.UUID {
s.mu.Lock()
defer s.mu.Unlock()
return append([]uuid.UUID(nil), s.claimTokens...)
}
func (s *shareJoinCapacityStub) releases() []uuid.UUID {
s.mu.Lock()
defer s.mu.Unlock()
return append([]uuid.UUID(nil), s.releaseTokens...)
}
func pgInt32(v int32) *int32 { return &v }
func clearShareLinksForTestWorkspace(t *testing.T) {
t.Helper()
ctx := context.Background()
if _, err := testPool.Exec(ctx,
`DELETE FROM workspace_share_link WHERE workspace_id = $1`,
parseUUID(testWorkspaceID),
); err != nil {
t.Fatalf("clear share links: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM workspace_share_link WHERE workspace_id = $1`,
parseUUID(testWorkspaceID),
)
})
}
// createTestUserAndMember inserts a fresh user and (optionally) a member row in
// the test workspace with the given role. Returns the new user id.
func createTestUserAndMember(t *testing.T, role string) string {
t.Helper()
ctx := context.Background()
seq := testSeq.Add(1)
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
fmt.Sprintf("sharelink-test-%d", seq),
fmt.Sprintf("sharelink-test-%[email protected]", seq),
).Scan(&userID); err != nil {
t.Fatalf("create share-link test user: %v", err)
}
if role != "" {
if _, err := testPool.Exec(ctx,
`INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, $3)`,
parseUUID(testWorkspaceID), parseUUID(userID), role,
); err != nil {
t.Fatalf("create share-link test member: %v", err)
}
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM member WHERE user_id = $1`, parseUUID(userID))
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, parseUUID(userID))
})
return userID
}
// createTestShareLink inserts a share link directly and returns its code.
func createTestShareLink(t *testing.T, workspaceID, role string, maxUses int32, expireHours int32) db.WorkspaceShareLink {
t.Helper()
ctx := context.Background()
var link db.WorkspaceShareLink
err := testPool.QueryRow(ctx, `
INSERT INTO workspace_share_link (workspace_id, code, created_by, role, max_uses, expires_at)
VALUES ($1, $2, $3, $4,
CASE WHEN $5 = 0 THEN NULL ELSE $5 END,
CASE WHEN $6 = 0 THEN NULL ELSE now() + ($6 * interval '1 hour') END)
RETURNING id, workspace_id, code, created_by, role, expires_at, max_uses, use_count, is_active, created_at
`,
parseUUID(workspaceID),
fmt.Sprintf("testcode-%d", testSeq.Add(1)),
parseUUID(testUserID),
role,
pgInt32(maxUses),
pgInt32(expireHours),
).Scan(&link.ID, &link.WorkspaceID, &link.Code, &link.CreatedBy, &link.Role, &link.ExpiresAt, &link.MaxUses, &link.UseCount, &link.IsActive, &link.CreatedAt)
if err != nil {
t.Fatalf("create test share link: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM workspace_share_link WHERE id = $1`, link.ID)
})
return link
}
// --- Tests ---
func TestCreateShareLink_RequiresOwnerAdmin(t *testing.T) {
clearShareLinksForTestWorkspace(t)
r := chi.NewRouter()
r.Route("/api/workspaces/{id}", func(r chi.Router) {
r.Use(middleware.RequireWorkspaceRoleFromURL(testHandler.Queries, "id", "owner", "admin"))
r.Post("/share-links", testHandler.CreateShareLink)
})
// Owner succeeds.
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member"})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("owner create: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Plain member is denied.
memberID := createTestUserAndMember(t, "member")
req2 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member"})
req2.Header.Set("X-User-ID", memberID)
req2 = withURLParam(req2, "id", testWorkspaceID)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusForbidden {
t.Fatalf("member create: expected 403, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestListShareLinks_RequiresOwnerAdmin(t *testing.T) {
clearShareLinksForTestWorkspace(t)
createTestShareLink(t, testWorkspaceID, "member", 0, 0)
r := chi.NewRouter()
r.Route("/api/workspaces/{id}", func(r chi.Router) {
r.Use(middleware.RequireWorkspaceRoleFromURL(testHandler.Queries, "id", "owner", "admin"))
r.Get("/share-links", testHandler.ListShareLinks)
})
// Owner can list.
req := newRequest("GET", "/api/workspaces/"+testWorkspaceID+"/share-links", nil)
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("owner list: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Plain member denied.
memberID := createTestUserAndMember(t, "member")
req2 := newRequest("GET", "/api/workspaces/"+testWorkspaceID+"/share-links", nil)
req2.Header.Set("X-User-ID", memberID)
req2 = withURLParam(req2, "id", testWorkspaceID)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusForbidden {
t.Fatalf("member list: expected 403, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestCreateShareLink_InvalidatesOldLink(t *testing.T) {
clearShareLinksForTestWorkspace(t)
first := createTestShareLink(t, testWorkspaceID, "member", 0, 0)
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member"})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateShareLink(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var active bool
if err := testPool.QueryRow(context.Background(),
`SELECT is_active FROM workspace_share_link WHERE id = $1`, first.ID).Scan(&active); err != nil {
t.Fatalf("reload old link: %v", err)
}
if active {
t.Fatal("old share link must be deactivated after creating a new one")
}
}
func TestCreateShareLink_RejectsInvalidInput(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// Negative expires_in is rejected.
neg := -1
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", ExpiresIn: &neg})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateShareLink(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("negative expires_in: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Zero max_uses is rejected.
zero := 0
req2 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", MaxUses: &zero})
req2 = withURLParam(req2, "id", testWorkspaceID)
w2 := httptest.NewRecorder()
testHandler.CreateShareLink(w2, req2)
if w2.Code != http.StatusBadRequest {
t.Fatalf("zero max_uses: expected 400, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestCreateShareLink_RejectsOverflowingInput(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// max_uses above math.MaxInt32 wraps in int32(...) — must be rejected.
overMax := 2147483648 // MaxInt32 + 1
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", MaxUses: &overMax})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateShareLink(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("max_uses over MaxInt32: expected 400, got %d: %s", w.Code, w.Body.String())
}
// expires_in above the duration-safe bound overflows time.Duration * time.Hour.
overExp := int(expiresInMaxHours) + 1
req2 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", ExpiresIn: &overExp})
req2 = withURLParam(req2, "id", testWorkspaceID)
w2 := httptest.NewRecorder()
testHandler.CreateShareLink(w2, req2)
if w2.Code != http.StatusBadRequest {
t.Fatalf("expires_in over duration-safe bound: expected 400, got %d: %s", w2.Code, w2.Body.String())
}
// Boundary: expires_in == expiresInMaxHours must be accepted, and the
// stored expires_at must be strictly in the future (not wrapped negative).
maxExp := int(expiresInMaxHours)
req3 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", ExpiresIn: &maxExp})
req3 = withURLParam(req3, "id", testWorkspaceID)
w3 := httptest.NewRecorder()
testHandler.CreateShareLink(w3, req3)
if w3.Code != http.StatusCreated {
t.Fatalf("expires_in = duration-safe max: expected 201, got %d: %s", w3.Code, w3.Body.String())
}
var expiresAt pgtype.Timestamptz
if err := testPool.QueryRow(context.Background(),
`SELECT expires_at FROM workspace_share_link WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT 1`,
parseUUID(testWorkspaceID)).Scan(&expiresAt); err != nil {
t.Fatalf("load created link expiry: %v", err)
}
if !expiresAt.Valid || !expiresAt.Time.After(time.Now()) {
t.Fatalf("accepted max expires_in must persist a future expires_at, got %v", expiresAt.Time)
}
// Deactivate so a fresh active link can be created for the max_uses check.
if _, err := testPool.Exec(context.Background(),
`UPDATE workspace_share_link SET is_active = false WHERE workspace_id = $1`,
parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("deactivate for max_uses boundary: %v", err)
}
maxUse := 2147483647
req4 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member", MaxUses: &maxUse})
req4 = withURLParam(req4, "id", testWorkspaceID)
w4 := httptest.NewRecorder()
testHandler.CreateShareLink(w4, req4)
if w4.Code != http.StatusCreated {
t.Fatalf("max_uses = MaxInt32: expected 201, got %d: %s", w4.Code, w4.Body.String())
}
}
func TestJoinShareLink_ExpiredAndRevoked(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// Expired link.
expired := createTestShareLink(t, testWorkspaceID, "member", 0, -1)
userID := createTestUserAndMember(t, "")
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: expired.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expired join: expected 404, got %d: %s", w.Code, w.Body.String())
}
// Deactivate the expired link so the workspace has room for a new active one.
if _, err := testPool.Exec(context.Background(),
`UPDATE workspace_share_link SET is_active = false WHERE id = $1`, expired.ID); err != nil {
t.Fatalf("deactivate expired link: %v", err)
}
// Revoked link.
revoked := createTestShareLink(t, testWorkspaceID, "member", 0, 0)
if _, err := testPool.Exec(context.Background(),
`UPDATE workspace_share_link SET is_active = false WHERE id = $1`, revoked.ID); err != nil {
t.Fatalf("revoke link: %v", err)
}
req2 := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: revoked.Code})
req2.Header.Set("X-User-ID", userID)
w2 := httptest.NewRecorder()
testHandler.JoinByShareLink(w2, req2)
if w2.Code != http.StatusNotFound {
t.Fatalf("revoked join: expected 404, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestJoinShareLink_ConcurrentLastUse(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// max_uses = 1: only one concurrent join may succeed.
link := createTestShareLink(t, testWorkspaceID, "member", 1, 0)
const n = 4
results := make(chan int, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
userID := createTestUserAndMember(t, "")
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
results <- w.Code
}()
}
wg.Wait()
close(results)
ok := 0
conflict := 0
for code := range results {
switch code {
case http.StatusOK:
ok++
case http.StatusNotFound, http.StatusConflict:
conflict++
default:
t.Fatalf("unexpected join status: %d", code)
}
}
if ok != 1 {
t.Fatalf("max_uses=1: expected exactly 1 success, got %d", ok)
}
}
func TestJoinShareLink_DuplicateJoinDoesNotConsumeUse(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
// First join succeeds.
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first join: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Duplicate join is rejected and must NOT consume another use.
req2 := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req2.Header.Set("X-User-ID", userID)
w2 := httptest.NewRecorder()
testHandler.JoinByShareLink(w2, req2)
if w2.Code != http.StatusConflict {
t.Fatalf("duplicate join: expected 409, got %d: %s", w2.Code, w2.Body.String())
}
var useCount int32
if err := testPool.QueryRow(context.Background(),
`SELECT use_count FROM workspace_share_link WHERE id = $1`, link.ID).Scan(&useCount); err != nil {
t.Fatalf("reload use count: %v", err)
}
if useCount != 1 {
t.Fatalf("duplicate join must not consume a use: expected use_count=1, got %d", useCount)
}
}
func TestJoinShareLink_ReactivatesDeadLetterWithSameCapacityToken(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
stub := &shareJoinCapacityStub{}
useSeatCapacity(t, stub)
queries := db.New(testPool)
originalToken := uuid.New()
intent, err := queries.CreateOrReactivateShareJoinCapacityIntent(context.Background(), db.CreateOrReactivateShareJoinCapacityIntentParams{
WorkspaceID: link.WorkspaceID, OperationToken: uuidToPG(originalToken),
ShareLinkID: link.ID, UserID: parseUUID(userID),
NextAttemptAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM seat_capacity_outbox WHERE operation_token = $1`, intent.OperationToken)
})
if _, err := testPool.Exec(context.Background(), `
UPDATE seat_capacity_outbox
SET attempt_count = 10, dead_lettered_at = now(), last_error = 'stuck'
WHERE operation_token = $1
`, intent.OperationToken); err != nil {
t.Fatal(err)
}
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
tokens := stub.tokens()
if len(tokens) != 1 || tokens[0] != originalToken {
t.Fatalf("claim tokens=%v, want original %s", tokens, originalToken)
}
}
func TestJoinShareLink_ConcurrentSameUserUsesOneCapacityOperation(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
gate := &sync.WaitGroup{}
gate.Add(2)
stub := &shareJoinCapacityStub{claimGate: gate}
useSeatCapacity(t, stub)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM seat_capacity_outbox
WHERE workspace_id = $1 AND share_link_id = $2 AND user_id = $3
`, link.WorkspaceID, link.ID, parseUUID(userID))
})
start := make(chan struct{})
statuses := make(chan int, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
statuses <- w.Code
}()
}
close(start)
wg.Wait()
close(statuses)
var successes, conflicts int
for status := range statuses {
switch status {
case http.StatusOK:
successes++
case http.StatusConflict:
conflicts++
default:
t.Fatalf("unexpected concurrent join status %d", status)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("successes=%d conflicts=%d, want 1/1", successes, conflicts)
}
tokens := stub.tokens()
if len(tokens) != 2 || tokens[0] != tokens[1] {
t.Fatalf("concurrent claim tokens=%v, want one logical operation", tokens)
}
var members int
if err := testPool.QueryRow(context.Background(), `
SELECT count(*) FROM member WHERE workspace_id = $1 AND user_id = $2
`, link.WorkspaceID, parseUUID(userID)).Scan(&members); err != nil {
t.Fatal(err)
}
if members != 1 {
t.Fatalf("member rows=%d, want 1", members)
}
}
// pendingInvitationForShareJoiner inserts a pending email invitation for a
// share-link join test user and returns its id. role is the invitation's own
// role, which the join must NOT propagate to the member.
func pendingInvitationForShareJoiner(t *testing.T, userID, email, role string) string {
t.Helper()
return dbfx.Insert(t, "workspace_invitation", testutil.Cols{
"workspace_id": testWorkspaceID,
"inviter_id": testUserID,
"invitee_email": email,
"invitee_user_id": userID,
"role": role,
"status": "pending",
"expires_at": testutil.Raw("now() + interval '1 day'"),
})
}
func shareJoinerEmail(t *testing.T, userID string) string {
t.Helper()
var email string
if err := testPool.QueryRow(context.Background(),
`SELECT email FROM "user" WHERE id = $1`, parseUUID(userID),
).Scan(&email); err != nil {
t.Fatalf("load share-join test user email: %v", err)
}
return email
}
func invitationStatusOf(t *testing.T, invitationID string) (string, string) {
t.Helper()
var status, role string
if err := testPool.QueryRow(context.Background(),
`SELECT status, role FROM workspace_invitation WHERE id = $1`, parseUUID(invitationID),
).Scan(&status, &role); err != nil {
t.Fatalf("reload invitation: %v", err)
}
return status, role
}
// A user invited by email who joins through the share link instead must have
// that pending invitation settled in the same transaction: otherwise the row
// keeps holding its seat reservation, keeps showing in their pending list,
// and a later Accept on it dies on the CreateMember 409. The link's role
// wins — the user chose this entry point — and the invitation row keeps its
// own role, only its status is settled.
func TestJoinShareLink_SettlesPendingInvitation(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
invitationID := pendingInvitationForShareJoiner(t, userID, shareJoinerEmail(t, userID), "admin")
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
status, role := invitationStatusOf(t, invitationID)
if status != "accepted" {
t.Fatalf("invitation status = %q, want accepted", status)
}
if role != "admin" {
t.Fatalf("invitation role = %q, want untouched admin", role)
}
var memberRole string
if err := testPool.QueryRow(context.Background(),
`SELECT role FROM member WHERE workspace_id = $1 AND user_id = $2`,
parseUUID(testWorkspaceID), parseUUID(userID),
).Scan(&memberRole); err != nil {
t.Fatalf("load member: %v", err)
}
if memberRole != "member" {
t.Fatalf("member role = %q, want the share link's member role", memberRole)
}
}
// The settled invitation signals its conclusion on the same channel the
// accept path uses, so workspace admins refresh their pending-invitation
// list instead of holding a row that is already over.
func TestJoinShareLink_SettledInvitationPublishesAcceptedEvent(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
invitationID := pendingInvitationForShareJoiner(t, userID, shareJoinerEmail(t, userID), "member")
gotEvent := make(chan map[string]any, 1)
testHandler.Bus.Subscribe(protocol.EventInvitationAccepted, func(e events.Event) {
if payload, ok := e.Payload.(map[string]any); ok {
select {
case gotEvent <- payload:
default:
}
}
})
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
var payload map[string]any
select {
case payload = <-gotEvent:
default:
t.Fatalf("no invitation:accepted event published for the settled invitation")
}
if payload["invitation_id"] != invitationID {
t.Fatalf("event invitation_id = %v, want %s", payload["invitation_id"], invitationID)
}
}
// In managed capacity mode the settled invitation was holding a seat
// reservation keyed by the invitation id; the join's own confirmed claim now
// covers the seat, so the reservation must be released — otherwise the same
// user holds a member seat and an invitation reservation at once.
func TestJoinShareLink_SettledInvitationReleasesSeatReservation(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
email := shareJoinerEmail(t, userID)
invitationUUID := parseUUID(pendingInvitationForShareJoiner(t, userID, email, "member"))
stub := &shareJoinCapacityStub{}
useSeatCapacity(t, stub)
// Pre-create the reservation intent the way CreateInvitation does.
queries := db.New(testPool)
if _, err := queries.UpsertSeatCapacityIntent(context.Background(), db.UpsertSeatCapacityIntentParams{
WorkspaceID: link.WorkspaceID,
OperationToken: invitationUUID,
Action: seatcapacity.ActionReserveInvitation,
NextAttemptAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
SubjectID: invitationUUID,
InvitationID: invitationUUID,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true},
}); err != nil {
t.Fatalf("seed invitation reservation intent: %v", err)
}
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
released := stub.releases()
if len(released) != 1 || released[0] != uuid.UUID(invitationUUID.Bytes) {
t.Fatalf("release calls = %v, want the invitation reservation %s", released, invitationUUID)
}
var intents int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM seat_capacity_outbox WHERE operation_token = $1`, invitationUUID,
).Scan(&intents); err != nil {
t.Fatal(err)
}
if intents != 0 {
t.Fatalf("seat_capacity_outbox rows for the invitation = %d, want 0 after release", intents)
}
if status, _ := invitationStatusOf(t, invitationUUID.String()); status != "accepted" {
t.Fatalf("invitation status = %q, want accepted", status)
}
}
// Unmanaged capacity keeps no durable reservation for an invitation, so the
// settle must still succeed end to end and leave nothing stranded in the
// outbox.
func TestJoinShareLink_SettlesPendingInvitationUnmanagedCapacity(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "member", 5, 0)
userID := createTestUserAndMember(t, "")
invitationUUID := parseUUID(pendingInvitationForShareJoiner(t, userID, shareJoinerEmail(t, userID), "member"))
unmanaged := seatcapacity.Decision{Managed: false, Allowed: false}
stub := &shareJoinCapacityStub{claimDecision: &unmanaged, releaseDecision: &unmanaged}
useSeatCapacity(t, stub)
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
if status, _ := invitationStatusOf(t, invitationUUID.String()); status != "accepted" {
t.Fatalf("invitation status = %q, want accepted", status)
}
var intents int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM seat_capacity_outbox WHERE operation_token = $1`, invitationUUID,
).Scan(&intents); err != nil {
t.Fatal(err)
}
if intents != 0 {
t.Fatalf("seat_capacity_outbox rows for the invitation = %d, want 0", intents)
}
}
// A share-link join for a user without a pending invitation proceeds as
// before — the settle lookup misses and changes nothing.
func TestJoinShareLink_NoPendingInvitationJoinsNormally(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "admin", 5, 0)
userID := createTestUserAndMember(t, "")
req := newRequest("POST", "/api/share-links/join", JoinByShareLinkRequest{Code: link.Code})
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
testHandler.JoinByShareLink(w, req)
if w.Code != http.StatusOK {
t.Fatalf("join: expected 200, got %d: %s", w.Code, w.Body.String())
}
var members int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM member WHERE workspace_id = $1 AND user_id = $2`,
parseUUID(testWorkspaceID), parseUUID(userID),
).Scan(&members); err != nil {
t.Fatal(err)
}
if members != 1 {
t.Fatalf("member rows=%d, want 1", members)
}
}
func TestGetShareLinkInfo_PublicResponseSlim(t *testing.T) {
clearShareLinksForTestWorkspace(t)
link := createTestShareLink(t, testWorkspaceID, "admin", 0, 0)
req := newRequest("GET", "/api/share-links/"+link.Code, nil)
req = withURLParam(req, "code", link.Code)
w := httptest.NewRecorder()
testHandler.GetShareLinkInfo(w, req)
if w.Code != http.StatusOK {
t.Fatalf("public preview: expected 200, got %d: %s", w.Code, w.Body.String())
}
var raw map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("parse preview: %v", err)
}
if _, ok := raw["creator_email"]; ok {
t.Fatal("public preview must not expose the inviter's email")
}
if _, ok := raw["workspace_id"]; ok {
t.Fatal("public preview must not expose the internal workspace id")
}
if _, ok := raw["code"]; ok {
t.Fatal("public preview must not expose the invite code")
}
}
func TestShareLink_CrossWorkspaceDenied(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// A user who is a member of another workspace must not list/create links
// in the test workspace. Create a member in a throwaway workspace.
ctx := context.Background()
var otherWS string
if err := testPool.QueryRow(ctx,
`INSERT INTO workspace (name, slug) VALUES ($1, $2) RETURNING id`,
"ShareLink Other", fmt.Sprintf("sharelink-other-%d", testSeq.Add(1)),
).Scan(&otherWS); err != nil {
t.Fatalf("create other workspace: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, parseUUID(otherWS))
})
otherUser := createTestUserAndMember(t, "")
if _, err := testPool.Exec(ctx,
`INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')`,
parseUUID(otherWS), parseUUID(otherUser),
); err != nil {
t.Fatalf("add owner to other workspace: %v", err)
}
r := chi.NewRouter()
r.Route("/api/workspaces/{id}", func(r chi.Router) {
r.Use(middleware.RequireWorkspaceRoleFromURL(testHandler.Queries, "id", "owner", "admin"))
r.Post("/share-links", testHandler.CreateShareLink)
})
// otherUser is not a member of the test workspace -> must be denied.
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member"})
req.Header.Set("X-User-ID", otherUser)
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden && w.Code != http.StatusNotFound {
t.Fatalf("cross-workspace create: expected 403 or 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateShareLink_ConcurrentCreatesSingleActive(t *testing.T) {
clearShareLinksForTestWorkspace(t)
// Concurrent creates on the same workspace: the partial unique index
// (workspace_id WHERE is_active) serializes them so that at most one active
// link exists at any time — later creates deactivate the earlier one rather
// than both succeeding. Assert the invariant: exactly one active row.
const n = 3
results := make(chan int, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/share-links", CreateShareLinkRequest{Role: "member"})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateShareLink(w, req)
results <- w.Code
}()
}
wg.Wait()
close(results)
for code := range results {
if code != http.StatusCreated && code != http.StatusConflict && code != http.StatusInternalServerError {
t.Fatalf("unexpected create status: %d", code)
}
}
var active int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM workspace_share_link WHERE workspace_id = $1 AND is_active = true`,
parseUUID(testWorkspaceID),
).Scan(&active); err != nil {
t.Fatalf("count active links: %v", err)
}
if active != 1 {
t.Fatalf("concurrent creates: expected exactly 1 active link, got %d", active)
}
}