mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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.
888 lines
32 KiB
Go
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)
|
|
}
|
|
}
|