Files
accounted/lib/entitlements/__tests__/multi-user.test.ts
T
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy

Multiple people in one company becomes a paid capability (multi_user, the
eighth PAID key). Derived at access time from capability_grants, no status
column, no enforcement cron:

- entitled: active grant (trial/stripe/team/manual/comp), everyone works
- grace: newest grant expired < 20 days ago; countdown banner for everyone
  in companies with > 1 user; invites still allowed
- frozen: only role=owner resolves; other memberships go dormant (rows
  untouched, paying reactivates instantly); invites 403 with paid-plan upsell

Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin
untouched: they also run on self-hosts, where the gate never bites), gated
query fallback for service-role/API-key paths, setActiveCompany guard, MCP
company-access check, invite route. Middleware routes all-frozen users to a
new /paused page; the switcher greys locked companies.

Migration 20260901081417 (applied to staging): trial trigger seeds
multi_user, backfills for mid-trial companies, active Stripe subs, team
agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20
days) for existing unpaid multi-member companies. Daily cron mails owners at
grace start and last day. Strings in sv+en; pg-real + unit tests included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): multi-user seat gate hardening from skeptic review

- Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting
  it: the 20-day grace window hangs on an expired row, so a deleted one
  froze churned payers' staff instantly with no banner and no mail. Other
  stripe grants keep the freeze-and-retain delete.
- New SECURITY DEFINER company_multi_user_state() RPC (migration
  20260901083726, applied to staging) and RPC-first getMultiUserState:
  capability_grants RLS hides team-scoped rows from non-team users, so
  user-client reads misread byra-covered companies as frozen (switch
  refusal, wrong switcher locks).
- Byra-kind teams get a standing team-scoped multi_user grant (backfill +
  teams trigger): byra client companies have no company-scoped trial by
  design, so a grantless byra team would freeze every consultant and
  client user.
- Comped/manual companies with active PAID-key grants extend to multi_user
  (a comped company must not read as paying while locking out user two).
- /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403).
- PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user
  rows; the gated fallback would have frozen every non-owner mid-deploy).
- Grace cron: covers team-scoped lapses (byra agreement ending) and skips
  the start mail for the hand-mailed grandfather cohort.
- Tests updated/added across all touched surfaces; pg tests for the new
  RPC and byra trigger; trial-suppression pg test extended to 8 keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): decouple seat-gate env check and fail open on gate read throws

CI round 1 on #2099:
- isMultiUserEnforced no longer imports has-capability: several route test
  suites partially mock that module and the vitest mock guard threw from
  inside the v1 seat gate, turning expected 4xx responses into 500s.
  multi_user is never a connector capability, so the bypass reduces to the
  same env reads, now inlined.
- getMultiUserState wraps its resolution in a fail-open try/catch: a client
  without .rpc or a thrown network error must never lock users out.
- no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or()
  scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's
  timestamp .or(); all columns in both strings are literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3)

company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and
were granted to authenticated with a caller-supplied company UUID: any
logged-in user could probe an arbitrary company's billing state and grace
deadline across tenants. Migration 20260901091752 (applied to staging)
requires an auth.uid() membership in the target company when a JWT is
present, keeps service-role/definer contexts unrestricted, and clamps the
grace window to [0, 20] days. pg tests: stranger gets false/NULL, member
reads normally, oversized p_grace_days cannot widen the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:29:12 +02:00

249 lines
9.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
computeMultiUserState,
isMembershipDormant,
MULTI_USER_GRACE_DAYS,
} from '../multi-user-state'
import { getMultiUserState, isMembershipActive, isMultiUserEnforced } from '../multi-user'
const DAY_MS = 86_400_000
const COMPANY = '11111111-1111-4111-8111-111111111111'
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
type TableResult = { data: unknown; error?: unknown }
/**
* Per-table mock plus an rpc seam. getMultiUserState is RPC-first
* (company_multi_user_state); the default rpc result is a TRANSIENT error so
* the tests below exercise the grants-read fallback path unless they seed an
* rpc answer explicitly.
*/
function makeSupabase(
byTable: Record<string, TableResult>,
rpcResult: TableResult = { data: null, error: { code: '57014', message: 'statement timeout' } },
): SupabaseClient {
const chainFor = (table: string) => {
const result = byTable[table] ?? { data: null, error: null }
const chain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
}
return () => chain
},
},
)
return chain
}
return {
from: (t: string) => chainFor(t),
rpc: async () => ({ data: rpcResult.data ?? null, error: rpcResult.error ?? null }),
} as unknown as SupabaseClient
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('computeMultiUserState', () => {
const now = Date.now()
it('is entitled on a never-expiring grant', () => {
expect(computeMultiUserState([{ expires_at: null }], now).state).toBe('entitled')
})
it('is entitled on an unexpired grant even when an older one lapsed', () => {
const access = computeMultiUserState(
[{ expires_at: iso(-40 * DAY_MS) }, { expires_at: iso(60_000) }],
now,
)
expect(access.state).toBe('entitled')
expect(access.graceEndsAt).toBeNull()
})
it('is in grace right after expiry, with graceEndsAt = expiry + 20 days', () => {
const expiry = iso(-60_000)
const access = computeMultiUserState([{ expires_at: expiry }], now)
expect(access.state).toBe('grace')
expect(new Date(access.graceEndsAt!).getTime()).toBe(
new Date(expiry).getTime() + MULTI_USER_GRACE_DAYS * DAY_MS,
)
})
it('is still in grace on day 19 after the lapse', () => {
const access = computeMultiUserState([{ expires_at: iso(-19 * DAY_MS) }], now)
expect(access.state).toBe('grace')
})
it('is frozen once the lapse is 20 full days old', () => {
// Derive the expiry from the SAME clock the check uses: iso() reads
// Date.now() at call time, which sits a few ms after `now` and would
// land the boundary case back inside the grace window.
const expiry = new Date(now - MULTI_USER_GRACE_DAYS * DAY_MS - 1000).toISOString()
const access = computeMultiUserState([{ expires_at: expiry }], now)
expect(access.state).toBe('frozen')
expect(access.graceEndsAt).toBeNull()
})
it('the NEWEST expiry drives the grace window (a fresh manual grant extends it)', () => {
// Grandfather shape: trial lapsed long ago, backfill grant expired "now".
const access = computeMultiUserState(
[{ expires_at: iso(-56 * DAY_MS) }, { expires_at: iso(-1000) }],
now,
)
expect(access.state).toBe('grace')
})
it('is frozen when never granted', () => {
expect(computeMultiUserState([], now).state).toBe('frozen')
})
})
describe('isMembershipDormant', () => {
it('owners are never dormant, frozen company or not', () => {
expect(isMembershipDormant('owner', 'frozen')).toBe(false)
})
it('non-owners are dormant only in the frozen state', () => {
expect(isMembershipDormant('admin', 'frozen')).toBe(true)
expect(isMembershipDormant('member', 'frozen')).toBe(true)
expect(isMembershipDormant('viewer', 'frozen')).toBe(true)
expect(isMembershipDormant('member', 'grace')).toBe(false)
expect(isMembershipDormant('member', 'entitled')).toBe(false)
})
})
describe('getMultiUserState', () => {
it('is entitled on self-hosted without touching the DB', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({}) // would be frozen if the gate ran
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
})
it('is entitled under the dev bypass', async () => {
vi.stubEnv('NODE_ENV', 'development')
const supabase = makeSupabase({})
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
})
it('FORCE_PAYWALL activates the real gate in development', async () => {
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('FORCE_PAYWALL', 'true')
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [] },
})
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('frozen')
})
it('is frozen for a non-UUID company id', async () => {
const supabase = makeSupabase({})
expect((await getMultiUserState(supabase, 'not-a-uuid')).state).toBe('frozen')
})
it('reads grants and derives grace from the newest expiry', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
})
const access = await getMultiUserState(supabase, COMPANY)
expect(access.state).toBe('grace')
expect(access.graceEndsAt).not.toBeNull()
})
it('a known teamId skips the companies lookup and still resolves', async () => {
const supabase = makeSupabase({
// companies deliberately absent: querying it would resolve null and be harmless,
// but the team grant below is what entitles.
capability_grants: { data: [{ expires_at: null }] },
})
const access = await getMultiUserState(supabase, COMPANY, {
teamId: '22222222-2222-4222-8222-222222222222',
})
expect(access.state).toBe('entitled')
})
it('fails OPEN (entitled) on a grants read error', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: null, error: { message: 'boom' } },
})
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
})
it('prefers the SECURITY DEFINER state RPC over the grants read (team grants hidden by RLS)', async () => {
// The tables would say frozen (no visible rows: the byrå-client shape);
// the RPC sees the team grant and must win.
const supabase = makeSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [] },
},
{ data: [{ state: 'entitled', grace_ends_at: null }] },
)
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
})
it('passes the RPC grace deadline through', async () => {
const graceEnd = iso(5 * DAY_MS)
const supabase = makeSupabase({}, { data: [{ state: 'grace', grace_ends_at: graceEnd }] })
const access = await getMultiUserState(supabase, COMPANY)
expect(access.state).toBe('grace')
expect(access.graceEndsAt).toBe(graceEnd)
})
it('fails OPEN when the state RPC does not exist yet (deploy race, PGRST202)', async () => {
// Pre-migration there are no multi_user rows either, so the grants
// fallback would freeze every non-owner: PGRST202 must short-circuit to
// entitled instead of reaching the table path.
const supabase = makeSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [] }, // would read as frozen
},
{ data: null, error: { code: 'PGRST202', message: 'function not found' } },
)
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
})
})
describe('isMembershipActive', () => {
it('owner passes without a grants read', async () => {
const supabase = makeSupabase({}) // would be frozen if consulted
expect(await isMembershipActive(supabase, COMPANY, 'owner')).toBe(true)
})
it('non-owner in a frozen company is inactive', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [] },
})
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(false)
})
it('non-owner in grace stays active', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
})
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(true)
})
})
describe('isMultiUserEnforced', () => {
it('is enforced in the test environment (NODE_ENV=test, hosted)', () => {
expect(isMultiUserEnforced()).toBe(true)
})
it('is not enforced on self-hosted', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(isMultiUserEnforced()).toBe(false)
})
})