mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +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.
861 lines
34 KiB
Go
861 lines
34 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/seatcapacity"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
const invitationTestEmail = "[email protected]"
|
|
|
|
type stubSeatCapacity struct {
|
|
reserveDecision seatcapacity.Decision
|
|
reserveErr error
|
|
reserveCalls int
|
|
releaseCalls int
|
|
consumeCalls int
|
|
consumeDecision *seatcapacity.Decision
|
|
consumeErr error
|
|
}
|
|
|
|
func (*stubSeatCapacity) RecoveryAvailable() bool { return true }
|
|
|
|
func (s *stubSeatCapacity) ReserveInvitation(context.Context, uuid.UUID, uuid.UUID, time.Time) (seatcapacity.Decision, error) {
|
|
s.reserveCalls++
|
|
return s.reserveDecision, s.reserveErr
|
|
}
|
|
func (s *stubSeatCapacity) ClaimShareJoin(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
|
|
}
|
|
func (s *stubSeatCapacity) Consume(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
s.consumeCalls++
|
|
decision := seatcapacity.Decision{Managed: true, Allowed: true}
|
|
if s.consumeDecision != nil {
|
|
decision = *s.consumeDecision
|
|
}
|
|
return decision, s.consumeErr
|
|
}
|
|
func (s *stubSeatCapacity) Confirm(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
|
|
}
|
|
func (s *stubSeatCapacity) Release(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
s.releaseCalls++
|
|
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
|
|
}
|
|
func (s *stubSeatCapacity) ReleaseMember(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
return seatcapacity.Decision{Managed: true, Allowed: true}, nil
|
|
}
|
|
func (s *stubSeatCapacity) GetOperation(context.Context, uuid.UUID, uuid.UUID) (seatcapacity.Decision, error) {
|
|
return seatcapacity.Decision{}, nil
|
|
}
|
|
|
|
func useSeatCapacity(t *testing.T, executor seatcapacity.Executor) {
|
|
t.Helper()
|
|
previous := testHandler.SeatCapacity
|
|
testHandler.SeatCapacity = executor
|
|
t.Cleanup(func() {
|
|
testHandler.SeatCapacity = previous
|
|
})
|
|
}
|
|
|
|
type stubInvitationRateLimiter struct {
|
|
allowed bool
|
|
allowResult *bool
|
|
err error
|
|
allowErr error
|
|
retryAfter time.Duration
|
|
checkKeys []string
|
|
allowKeys []string
|
|
}
|
|
|
|
func (l *stubInvitationRateLimiter) Allow(ctx context.Context, key string) bool {
|
|
allowed, _ := l.AllowWithError(ctx, key)
|
|
return allowed
|
|
}
|
|
|
|
func (l *stubInvitationRateLimiter) AllowWithError(_ context.Context, key string) (bool, error) {
|
|
l.allowKeys = append(l.allowKeys, key)
|
|
if l.allowResult != nil {
|
|
return *l.allowResult, l.allowErr
|
|
}
|
|
return l.allowed, l.allowErr
|
|
}
|
|
|
|
func (l *stubInvitationRateLimiter) Check(ctx context.Context, key string) bool {
|
|
allowed, _ := l.CheckWithError(ctx, key)
|
|
return allowed
|
|
}
|
|
|
|
func (l *stubInvitationRateLimiter) CheckWithError(_ context.Context, key string) (bool, error) {
|
|
l.checkKeys = append(l.checkKeys, key)
|
|
return l.allowed, l.err
|
|
}
|
|
|
|
func (l *stubInvitationRateLimiter) RetryAfter(_ context.Context, _ string) time.Duration {
|
|
return l.retryAfter
|
|
}
|
|
|
|
func useInvitationRateLimiters(t *testing.T, limiters InvitationRateLimiters) {
|
|
t.Helper()
|
|
previous := testHandler.InvitationRateLimiters
|
|
testHandler.InvitationRateLimiters = limiters
|
|
t.Cleanup(func() {
|
|
testHandler.InvitationRateLimiters = previous
|
|
})
|
|
}
|
|
|
|
func clearInvitationsForTestWorkspace(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if _, err := testPool.Exec(ctx,
|
|
`DELETE FROM workspace_invitation WHERE workspace_id = $1`,
|
|
parseUUID(testWorkspaceID),
|
|
); err != nil {
|
|
t.Fatalf("clear invitations: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(),
|
|
`DELETE FROM workspace_invitation WHERE workspace_id = $1`,
|
|
parseUUID(testWorkspaceID),
|
|
)
|
|
})
|
|
}
|
|
|
|
type rollbackOnCommitTx struct {
|
|
pgx.Tx
|
|
}
|
|
|
|
func (tx rollbackOnCommitTx) Commit(ctx context.Context) error {
|
|
_ = tx.Tx.Rollback(ctx)
|
|
return errors.New("forced commit failure")
|
|
}
|
|
|
|
type rollbackOnCommitTxStarter struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func (s rollbackOnCommitTxStarter) Begin(ctx context.Context) (pgx.Tx, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rollbackOnCommitTx{Tx: tx}, nil
|
|
}
|
|
|
|
// Sanity check: a fresh, live pending invitation must block re-invitation.
|
|
func TestCreateInvitation_BlocksWhilePending(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
actor := &stubInvitationRateLimiter{allowed: true}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
|
|
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: invitationTestEmail,
|
|
Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("first invite: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
req2 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: invitationTestEmail,
|
|
Role: "member",
|
|
})
|
|
req2 = withURLParam(req2, "id", testWorkspaceID)
|
|
w2 := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(w2, req2)
|
|
if w2.Code != http.StatusConflict {
|
|
t.Fatalf("second invite: expected 409 while still pending, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor checks": len(actor.checkKeys), "workspace checks": len(workspace.checkKeys), "recipient checks": len(recipient.checkKeys),
|
|
"actor allows": len(actor.allowKeys), "workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 1 {
|
|
t.Errorf("%s limiter calls = %d, want 1; a pending retry must not consume budget", name, calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_BlocksWhenPurchasedCapacityIsFull(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
if _, err := testPool.Exec(context.Background(), `DELETE FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); err != nil {
|
|
t.Fatalf("clear capacity outbox: %v", err)
|
|
}
|
|
capacity := &stubSeatCapacity{reserveDecision: seatcapacity.Decision{
|
|
Managed: true, Allowed: false, Reason: "capacity_full",
|
|
}}
|
|
useSeatCapacity(t, capacity)
|
|
actor := &stubInvitationRateLimiter{allowed: true}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: "[email protected]", Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Code != "seat_capacity_full" {
|
|
t.Fatalf("code = %q, want seat_capacity_full", body.Code)
|
|
}
|
|
if capacity.reserveCalls != 1 {
|
|
t.Fatalf("reserve calls = %d, want 1", capacity.reserveCalls)
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor checks": len(actor.checkKeys), "workspace checks": len(workspace.checkKeys), "recipient checks": len(recipient.checkKeys),
|
|
} {
|
|
if calls != 1 {
|
|
t.Errorf("%s = %d, want 1", name, calls)
|
|
}
|
|
}
|
|
if calls := len(actor.allowKeys); calls != 1 {
|
|
t.Errorf("actor allows = %d, want 1 when persistent capacity_full is rejected", calls)
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 0 {
|
|
t.Errorf("%s = %d, want 0 so a later purchased-seat invitation keeps shared budget", name, calls)
|
|
}
|
|
}
|
|
var invitationCount, outboxCount int
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM workspace_invitation WHERE workspace_id = $1 AND invitee_email = $2`, parseUUID(testWorkspaceID), "[email protected]").Scan(&invitationCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)).Scan(&outboxCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if invitationCount != 0 || outboxCount != 0 {
|
|
t.Fatalf("persisted invitation=%d outbox=%d, want both zero", invitationCount, outboxCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_BlocksOvercommittedCapacityWithoutOfferingSingleSeatSemantics(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
capacity := &stubSeatCapacity{reserveErr: &seatcapacity.HTTPError{
|
|
StatusCode: http.StatusConflict,
|
|
Code: "capacity_overcommitted",
|
|
}}
|
|
useSeatCapacity(t, capacity)
|
|
actor := &stubInvitationRateLimiter{allowed: true}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: "[email protected]", Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Code != "seat_capacity_overcommitted" {
|
|
t.Fatalf("code = %q, want seat_capacity_overcommitted", body.Code)
|
|
}
|
|
if capacity.reserveCalls != 1 {
|
|
t.Fatalf("reserve calls = %d, want 1", capacity.reserveCalls)
|
|
}
|
|
if calls := len(actor.allowKeys); calls != 1 {
|
|
t.Errorf("actor allows = %d, want 1 when persistent capacity_overcommitted is rejected", calls)
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 0 {
|
|
t.Errorf("%s = %d, want 0 while capacity remains overcommitted", name, calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_MapsCloudCapacityRateLimitWithoutConsumingInvitationBudget(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
if _, err := testPool.Exec(context.Background(), `DELETE FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); err != nil {
|
|
t.Fatalf("clear capacity outbox: %v", err)
|
|
}
|
|
capacity := &stubSeatCapacity{reserveErr: &seatcapacity.HTTPError{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
RetryAfter: 3 * time.Second,
|
|
}}
|
|
useSeatCapacity(t, capacity)
|
|
actor := &stubInvitationRateLimiter{allowed: true}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: "[email protected]", Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("Retry-After") != "3" || rec.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("unexpected retry/cache headers: %v", rec.Header())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Code != "seat_capacity_rate_limited" {
|
|
t.Fatalf("code = %q, want seat_capacity_rate_limited", body.Code)
|
|
}
|
|
if capacity.reserveCalls != 1 || capacity.releaseCalls != 0 {
|
|
t.Fatalf("capacity calls reserve=%d release=%d, want 1/0", capacity.reserveCalls, capacity.releaseCalls)
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor allows": len(actor.allowKeys), "workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 0 {
|
|
t.Errorf("%s = %d, want 0 after Cloud rate limit", name, calls)
|
|
}
|
|
}
|
|
if intents := dbfx.Count(t, `SELECT count(*) FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); intents != 0 {
|
|
t.Fatalf("capacity intents = %d, want 0 after Cloud rate limit", intents)
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_CompensatesCapacityWhenCommitRollsBack(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
ctx := context.Background()
|
|
workspaceID := parseUUID(testWorkspaceID)
|
|
if _, err := testPool.Exec(ctx, `DELETE FROM seat_capacity_outbox WHERE workspace_id = $1`, workspaceID); err != nil {
|
|
t.Fatalf("clear capacity outbox: %v", err)
|
|
}
|
|
|
|
capacity := &stubSeatCapacity{reserveDecision: seatcapacity.Decision{Managed: true, Allowed: true}}
|
|
useSeatCapacity(t, capacity)
|
|
previousTxStarter := testHandler.TxStarter
|
|
testHandler.TxStarter = rollbackOnCommitTxStarter{pool: testPool}
|
|
t.Cleanup(func() { testHandler.TxStarter = previousTxStarter })
|
|
|
|
const email = "[email protected]"
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: email, Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if capacity.reserveCalls != 1 || capacity.releaseCalls != 1 {
|
|
t.Fatalf("reserve calls = %d, release calls = %d; want one each", capacity.reserveCalls, capacity.releaseCalls)
|
|
}
|
|
|
|
var invitationCount, outboxCount int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM workspace_invitation WHERE workspace_id = $1 AND invitee_email = $2`, workspaceID, email).Scan(&invitationCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM seat_capacity_outbox WHERE workspace_id = $1`, workspaceID).Scan(&outboxCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if invitationCount != 0 || outboxCount != 0 {
|
|
t.Fatalf("persisted invitation=%d outbox=%d, want both zero after compensation", invitationCount, outboxCount)
|
|
}
|
|
}
|
|
|
|
// Regression for issue #2055: an expired pending invitation must NOT block a
|
|
// new invitation to the same email. The stale row should be flipped to
|
|
// 'expired' and a fresh pending row should be created.
|
|
func TestCreateInvitation_AllowsAfterExpiry(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
ctx := context.Background()
|
|
|
|
var staleID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace_invitation (
|
|
workspace_id, inviter_id, invitee_email, role, status, created_at, updated_at, expires_at
|
|
)
|
|
VALUES ($1, $2, $3, 'member', 'pending', now() - interval '10 days', now() - interval '10 days', now() - interval '3 days')
|
|
RETURNING id
|
|
`, parseUUID(testWorkspaceID), parseUUID(testUserID), invitationTestEmail).Scan(&staleID); err != nil {
|
|
t.Fatalf("seed expired invitation: %v", err)
|
|
}
|
|
|
|
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: invitationTestEmail,
|
|
Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("re-invite after expiry: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp InvitationResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.ID == "" || resp.ID == staleID {
|
|
t.Fatalf("expected a new invitation row, got id=%q (stale=%q)", resp.ID, staleID)
|
|
}
|
|
|
|
var staleStatus string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT status FROM workspace_invitation WHERE id = $1`, staleID,
|
|
).Scan(&staleStatus); err != nil {
|
|
t.Fatalf("read stale row: %v", err)
|
|
}
|
|
if staleStatus != "expired" {
|
|
t.Fatalf("expected stale row to be 'expired', got %q", staleStatus)
|
|
}
|
|
|
|
var pendingCount int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM workspace_invitation
|
|
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending'
|
|
`, parseUUID(testWorkspaceID), invitationTestEmail).Scan(&pendingCount); err != nil {
|
|
t.Fatalf("count pending: %v", err)
|
|
}
|
|
if pendingCount != 1 {
|
|
t.Fatalf("expected exactly 1 pending invitation after re-invite, got %d", pendingCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_RateLimitChecksEveryGateBeforeCapacityReservation(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
if _, err := testPool.Exec(context.Background(), `DELETE FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); err != nil {
|
|
t.Fatalf("clear capacity outbox: %v", err)
|
|
}
|
|
capacity := &stubSeatCapacity{reserveDecision: seatcapacity.Decision{Managed: true, Allowed: true}}
|
|
useSeatCapacity(t, capacity)
|
|
actor := &stubInvitationRateLimiter{allowed: false, retryAfter: 10 * time.Minute}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: false, retryAfter: 24 * time.Hour}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
|
|
const rawEmail = " [email protected] "
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: rawEmail,
|
|
Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("Retry-After"); got != "86400" {
|
|
t.Errorf("Retry-After = %q, want 86400", got)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
|
t.Errorf("Cache-Control = %q, want no-store", got)
|
|
}
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
Code string `json:"code"`
|
|
RetryAfterSeconds int64 `json:"retry_after_seconds"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if body.Error != "too many invitation requests" || body.Code != "invitation_rate_limited" || body.RetryAfterSeconds != 86400 {
|
|
t.Errorf("unexpected body: %+v", body)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "actor") || strings.Contains(rec.Body.String(), "workspace") || strings.Contains(rec.Body.String(), "recipient") {
|
|
t.Errorf("response leaked the limiting gate: %s", rec.Body.String())
|
|
}
|
|
|
|
normalizedEmail := strings.ToLower(strings.TrimSpace(rawEmail))
|
|
checks := []struct {
|
|
name string
|
|
got []string
|
|
want string
|
|
}{
|
|
{name: "actor", got: actor.checkKeys, want: testUserID},
|
|
{name: "workspace", got: workspace.checkKeys, want: testWorkspaceID},
|
|
{name: "recipient", got: recipient.checkKeys, want: invitationRecipientKey(normalizedEmail)},
|
|
}
|
|
for _, check := range checks {
|
|
if len(check.got) != 1 || check.got[0] != check.want {
|
|
t.Errorf("%s limiter keys = %v, want [%s]", check.name, check.got, check.want)
|
|
}
|
|
}
|
|
if len(recipient.checkKeys) == 1 && (recipient.checkKeys[0] == normalizedEmail || strings.Contains(recipient.checkKeys[0], "@")) {
|
|
t.Errorf("recipient limiter key contains the email instead of a digest: %q", recipient.checkKeys[0])
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor": len(actor.allowKeys), "workspace": len(workspace.allowKeys), "recipient": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 0 {
|
|
t.Errorf("%s limiter consumed %d times, want 0 after a rejected check", name, calls)
|
|
}
|
|
}
|
|
|
|
var pendingCount int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT COUNT(*) FROM workspace_invitation
|
|
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending'
|
|
`, parseUUID(testWorkspaceID), normalizedEmail).Scan(&pendingCount); err != nil {
|
|
t.Fatalf("count pending invitations: %v", err)
|
|
}
|
|
if pendingCount != 0 {
|
|
t.Fatalf("pending invitation count = %d, want 0 after rate limit rejection", pendingCount)
|
|
}
|
|
if capacity.reserveCalls != 0 || capacity.releaseCalls != 0 {
|
|
t.Fatalf("capacity calls reserve=%d release=%d, want 0/0", capacity.reserveCalls, capacity.releaseCalls)
|
|
}
|
|
if intents := dbfx.Count(t, `SELECT count(*) FROM seat_capacity_outbox WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); intents != 0 {
|
|
t.Fatalf("capacity intents = %d, want 0 after compensated rate-limit rejection", intents)
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_RateLimiterFailureReturnsServiceUnavailable(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
actor := &stubInvitationRateLimiter{allowed: false, retryAfter: 10 * time.Minute}
|
|
workspace := &stubInvitationRateLimiter{allowed: true, err: errors.New("redis unavailable")}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
useInvitationRateLimiters(t, InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient})
|
|
previousMetrics := testHandler.Metrics
|
|
testHandler.Metrics = obsmetrics.NewBusinessMetrics()
|
|
t.Cleanup(func() {
|
|
testHandler.Metrics = previousMetrics
|
|
})
|
|
|
|
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
|
|
Email: "[email protected]",
|
|
Role: "member",
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
rec := httptest.NewRecorder()
|
|
testHandler.CreateInvitation(rec, req)
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("Retry-After") != "5" || rec.Header().Get("Cache-Control") != "no-store" {
|
|
t.Errorf("unexpected retry/cache headers: %v", rec.Header())
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if body["code"] != "invitation_rate_limiter_unavailable" {
|
|
t.Errorf("code = %q, want invitation_rate_limiter_unavailable", body["code"])
|
|
}
|
|
metricFamily := obsmetrics.GatherForTest(t, testHandler.Metrics)["multica_email_rate_limited_total"]
|
|
for _, metric := range metricFamily.GetMetric() {
|
|
if metric.GetCounter().GetValue() != 0 {
|
|
t.Errorf("rate-limit metric = %v, want 0 when the final response is 503", metric.GetCounter().GetValue())
|
|
}
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor checks": len(actor.checkKeys), "workspace checks": len(workspace.checkKeys), "recipient checks": len(recipient.checkKeys),
|
|
} {
|
|
if calls != 1 {
|
|
t.Errorf("%s = %d, want 1 even when another gate errors", name, calls)
|
|
}
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor": len(actor.allowKeys), "workspace": len(workspace.allowKeys), "recipient": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 0 {
|
|
t.Errorf("%s limiter consumed %d times, want 0 after a backend error", name, calls)
|
|
}
|
|
}
|
|
var pendingCount int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT COUNT(*) FROM workspace_invitation
|
|
WHERE workspace_id = $1 AND invitee_email = '[email protected]' AND status = 'pending'
|
|
`, parseUUID(testWorkspaceID)).Scan(&pendingCount); err != nil {
|
|
t.Fatalf("count pending invitations: %v", err)
|
|
}
|
|
if pendingCount != 0 {
|
|
t.Fatalf("pending invitation count = %d, want 0 after limiter backend failure", pendingCount)
|
|
}
|
|
}
|
|
|
|
func TestInvitationAdmission_RejectedActorDoesNotConsumeWorkspaceBudget(t *testing.T) {
|
|
limits := InvitationRateLimits{
|
|
Actor: SlidingWindowRateLimit{Limit: 1, Window: time.Hour},
|
|
Workspace: SlidingWindowRateLimit{Limit: 2, Window: time.Hour},
|
|
Recipient: SlidingWindowRateLimit{Limit: 10, Window: time.Hour},
|
|
}
|
|
h := *testHandler
|
|
h.InvitationRateLimiters = NewMemoryInvitationRateLimiters(limits)
|
|
|
|
admit := func(actorID, email string) bool {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/workspace-a/members", nil)
|
|
admission, ok := h.checkInvitationAdmission(httptest.NewRecorder(), req, actorID, "workspace-a", email)
|
|
if ok {
|
|
h.consumeInvitationAdmission(req, admission)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
if !admit("actor-a", "[email protected]") {
|
|
t.Fatal("first invitation was unexpectedly rejected")
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if admit("actor-a", fmt.Sprintf("rejected-%[email protected]", i)) {
|
|
t.Fatalf("actor-limited invitation %d was unexpectedly admitted", i)
|
|
}
|
|
}
|
|
if !admit("actor-b", "[email protected]") {
|
|
t.Fatal("actor-limited retries consumed the shared workspace budget")
|
|
}
|
|
}
|
|
|
|
func TestInvitationAdmission_RejectedActorDoesNotConsumeRecipientBudget(t *testing.T) {
|
|
limits := InvitationRateLimits{
|
|
Actor: SlidingWindowRateLimit{Limit: 1, Window: time.Hour},
|
|
Workspace: SlidingWindowRateLimit{Limit: 10, Window: time.Hour},
|
|
Recipient: SlidingWindowRateLimit{Limit: 2, Window: time.Hour},
|
|
}
|
|
h := *testHandler
|
|
h.InvitationRateLimiters = NewMemoryInvitationRateLimiters(limits)
|
|
|
|
admit := func(actorID, workspaceID string) bool {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/"+workspaceID+"/members", nil)
|
|
admission, ok := h.checkInvitationAdmission(httptest.NewRecorder(), req, actorID, workspaceID, "[email protected]")
|
|
if ok {
|
|
h.consumeInvitationAdmission(req, admission)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
if !admit("actor-a", "workspace-a") {
|
|
t.Fatal("first invitation was unexpectedly rejected")
|
|
}
|
|
if admit("actor-a", "workspace-a") {
|
|
t.Fatal("actor-limited retry was unexpectedly admitted")
|
|
}
|
|
if !admit("actor-b", "workspace-b") {
|
|
t.Fatal("actor-limited retry consumed the global recipient budget")
|
|
}
|
|
}
|
|
|
|
func TestInvitationAdmission_AllowsBoundedOvershootWhenGateFillsAfterCheck(t *testing.T) {
|
|
allowDenied := false
|
|
actor := &stubInvitationRateLimiter{allowed: true, allowResult: &allowDenied}
|
|
workspace := &stubInvitationRateLimiter{allowed: true}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
h := *testHandler
|
|
h.InvitationRateLimiters = InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/workspace-a/members", nil)
|
|
admission, ok := h.checkInvitationAdmission(httptest.NewRecorder(), req, "actor-a", "workspace-a", "[email protected]")
|
|
if !ok {
|
|
t.Fatal("invitation was rejected during the non-consuming check")
|
|
}
|
|
h.consumeInvitationAdmission(req, admission)
|
|
for name, calls := range map[string]int{
|
|
"actor checks": len(actor.checkKeys), "workspace checks": len(workspace.checkKeys), "recipient checks": len(recipient.checkKeys),
|
|
"actor allows": len(actor.allowKeys), "workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 1 {
|
|
t.Errorf("%s = %d, want 1", name, calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvitationAdmission_AllowsBoundedOvershootWhenBackendFailsAfterChecks(t *testing.T) {
|
|
actor := &stubInvitationRateLimiter{allowed: true}
|
|
workspace := &stubInvitationRateLimiter{allowed: true, allowErr: errors.New("redis unavailable")}
|
|
recipient := &stubInvitationRateLimiter{allowed: true}
|
|
h := *testHandler
|
|
h.InvitationRateLimiters = InvitationRateLimiters{Actor: actor, Workspace: workspace, Recipient: recipient}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/workspace-a/members", nil)
|
|
rec := httptest.NewRecorder()
|
|
admission, ok := h.checkInvitationAdmission(rec, req, "actor-a", "workspace-a", "[email protected]")
|
|
if !ok {
|
|
t.Fatalf("invitation was rejected during successful checks with %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
h.consumeInvitationAdmission(req, admission)
|
|
if rec.Body.Len() != 0 || rec.Header().Get("Retry-After") != "" {
|
|
t.Fatalf("backend failure after checks wrote an error response: headers=%v body=%s", rec.Header(), rec.Body.String())
|
|
}
|
|
for name, calls := range map[string]int{
|
|
"actor checks": len(actor.checkKeys), "workspace checks": len(workspace.checkKeys), "recipient checks": len(recipient.checkKeys),
|
|
"actor allows": len(actor.allowKeys), "workspace allows": len(workspace.allowKeys), "recipient allows": len(recipient.allowKeys),
|
|
} {
|
|
if calls != 1 {
|
|
t.Errorf("%s = %d, want 1", name, calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateInvitation_RouteRequiresAdminRole(t *testing.T) {
|
|
clearInvitationsForTestWorkspace(t)
|
|
ctx := context.Background()
|
|
const memberEmail = "[email protected]"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, memberEmail)
|
|
var memberUserID string
|
|
if err := testPool.QueryRow(ctx, `INSERT INTO "user" (name, email) VALUES ('Invitation Route Member', $1) RETURNING id`, memberEmail).Scan(&memberUserID); err != nil {
|
|
t.Fatalf("create member user: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')`, testWorkspaceID, memberUserID); err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, memberUserID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, memberUserID)
|
|
})
|
|
|
|
router := chi.NewRouter()
|
|
router.Route("/api/workspaces/{id}", func(r chi.Router) {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(testHandler.Queries, "id", "owner", "admin"))
|
|
r.Post("/members", testHandler.CreateInvitation)
|
|
})
|
|
})
|
|
|
|
call := func(userID, inviteeEmail string) *httptest.ResponseRecorder {
|
|
var body bytes.Buffer
|
|
if err := json.NewEncoder(&body).Encode(CreateMemberRequest{Email: inviteeEmail, Role: "member"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/members", &body)
|
|
req.Header.Set("X-User-ID", userID)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
if rec := call(memberUserID, "[email protected]"); rec.Code != http.StatusForbidden {
|
|
t.Errorf("member status = %d, want 403: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := call(testUserID, fmt.Sprintf("invitation-route-owner-%[email protected]", testWorkspaceID)); rec.Code != http.StatusCreated {
|
|
t.Errorf("owner status = %d, want 201: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// An installed client can still show a pending-invitation row the user
|
|
// already accepted from another surface (web invite page, another device).
|
|
// Re-accept must be idempotent for the membership the first accept created,
|
|
// so the client's refetch drops the stale row instead of sticking forever.
|
|
func TestAcceptInvitation_AlreadyAcceptedIsIdempotentForExistingMember(t *testing.T) {
|
|
email := "idempotent-accept-" + uuid.NewString() + "@multica.ai"
|
|
userID := dbfx.User(t, "Idempotent Accept Invitee", email)
|
|
invitationID := dbfx.Insert(t, "workspace_invitation", testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"inviter_id": testUserID,
|
|
"invitee_email": email,
|
|
"invitee_user_id": userID,
|
|
"role": "member",
|
|
"status": "accepted",
|
|
"expires_at": testutil.Raw("now() + interval '1 day'"),
|
|
})
|
|
memberID := dbfx.Member(t, testWorkspaceID, userID, "member")
|
|
|
|
req := newRequest("POST", "/api/invitations/"+invitationID+"/accept", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
req = withURLParam(req, "id", invitationID)
|
|
var member MemberWithUserResponse
|
|
testutil.Call(t, testHandler.AcceptInvitation, req).Want(http.StatusOK).JSON(&member)
|
|
if member.ID != memberID {
|
|
t.Fatalf("member id = %q, want %q", member.ID, memberID)
|
|
}
|
|
if members := dbfx.Count(t, `SELECT count(*) FROM member WHERE workspace_id = $1 AND user_id = $2`, parseUUID(testWorkspaceID), parseUUID(userID)); members != 1 {
|
|
t.Fatalf("member count = %d, want 1", members)
|
|
}
|
|
}
|
|
|
|
// A concluded invitation whose membership is gone (the user left the
|
|
// workspace afterwards) must not be re-acceptable: leaving was explicit.
|
|
func TestAcceptInvitation_AlreadyAcceptedWithoutMembershipStillFails(t *testing.T) {
|
|
email := "stale-accept-no-member-" + uuid.NewString() + "@multica.ai"
|
|
userID := dbfx.User(t, "Stale Accept Invitee", email)
|
|
invitationID := dbfx.Insert(t, "workspace_invitation", testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"inviter_id": testUserID,
|
|
"invitee_email": email,
|
|
"invitee_user_id": userID,
|
|
"role": "member",
|
|
"status": "accepted",
|
|
"expires_at": testutil.Raw("now() + interval '1 day'"),
|
|
})
|
|
|
|
req := newRequest("POST", "/api/invitations/"+invitationID+"/accept", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
req = withURLParam(req, "id", invitationID)
|
|
testutil.Call(t, testHandler.AcceptInvitation, req).Want(http.StatusBadRequest)
|
|
if members := dbfx.Count(t, `SELECT count(*) FROM member WHERE workspace_id = $1 AND user_id = $2`, parseUUID(testWorkspaceID), parseUUID(userID)); members != 0 {
|
|
t.Fatalf("member count = %d, want 0", members)
|
|
}
|
|
}
|
|
|
|
// Declining a concluded invitation is a no-op: the row only lingers in
|
|
// clients that concluded the same invitation elsewhere, and a 204 lets their
|
|
// refetch drop it.
|
|
func TestDeclineInvitation_AlreadyAcceptedIsNoOp(t *testing.T) {
|
|
email := "idempotent-decline-" + uuid.NewString() + "@multica.ai"
|
|
userID := dbfx.User(t, "Idempotent Decline Invitee", email)
|
|
invitationID := dbfx.Insert(t, "workspace_invitation", testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"inviter_id": testUserID,
|
|
"invitee_email": email,
|
|
"invitee_user_id": userID,
|
|
"role": "member",
|
|
"status": "accepted",
|
|
"expires_at": testutil.Raw("now() + interval '1 day'"),
|
|
})
|
|
dbfx.Member(t, testWorkspaceID, userID, "member")
|
|
|
|
req := newRequest("POST", "/api/invitations/"+invitationID+"/decline", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
req = withURLParam(req, "id", invitationID)
|
|
testutil.Call(t, testHandler.DeclineInvitation, req).Want(http.StatusNoContent)
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(context.Background(), `SELECT status FROM workspace_invitation WHERE id = $1`, parseUUID(invitationID)).Scan(&status); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != "accepted" {
|
|
t.Fatalf("invitation status = %q, want accepted", status)
|
|
}
|
|
if members := dbfx.Count(t, `SELECT count(*) FROM member WHERE workspace_id = $1 AND user_id = $2`, parseUUID(testWorkspaceID), parseUUID(userID)); members != 1 {
|
|
t.Fatalf("member count = %d, want 1", members)
|
|
}
|
|
}
|