Files
accounted/lib/company/__tests__/context.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

463 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockCookieSet } = vi.hoisted(() => ({ mockCookieSet: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })),
}))
// Multi-user seat gate seam. Default: NOT enforced and every membership
// active, so the pre-existing tests keep documenting the ungated resolution
// (the self-hosted / dev behavior, and the pre-gate semantics). The gated
// tests at the bottom flip these; the gate's own logic is covered in
// lib/entitlements/__tests__/multi-user.test.ts.
const multiUserSeam = vi.hoisted(() => ({
enforced: false,
membershipActive: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/entitlements/multi-user', async () => {
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
'@/lib/entitlements/multi-user',
)
return {
...actual,
isMultiUserEnforced: () => multiUserSeam.enforced,
isMembershipActive: (...args: unknown[]) => multiUserSeam.membershipActive(...args),
}
})
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
type CapturedCall = { table: string; method: string; args: unknown[] }
type TerminalResult = { data?: unknown; error?: unknown }
/**
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
* terminates with `results[table][method]` when seeded, otherwise keeps
* chaining. setActiveCompany ends both its queries on `.single()`, on
* different tables, so seeding `single` per table drives each branch.
* A terminal seeded as an ARRAY is consumed in call order, for functions
* that query the same table twice (getActiveCompanyId's fallback fetch +
* preference validation both end on company_members.maybeSingle()).
*
* `rpcResult` seeds supabase.rpc('resolve_active_company'). The default is a
* PGRST202 "function not found" error so every pre-RPC test keeps passing
* unchanged: they now exercise the query fallback path, which is exactly the
* behavior on a not-yet-migrated database.
*/
function buildSupabase(
results: Record<string, Record<string, TerminalResult | TerminalResult[]>>,
rpcResult: TerminalResult = {
data: null,
error: { code: 'PGRST202', message: 'Could not find the function' },
},
) {
const calls: CapturedCall[] = []
function makeChain(table: string) {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'or', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args })
const seeded = results[table]?.[m]
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
if (terminal) {
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
}
return chain
}
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
}
const supabase = {
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
rpc: vi.fn(async () => ({
data: rpcResult.data ?? null,
error: rpcResult.error ?? null,
})),
}
return { supabase, calls }
}
beforeEach(() => {
vi.clearAllMocks()
multiUserSeam.enforced = false
multiUserSeam.membershipActive.mockResolvedValue(true)
})
describe('setActiveCompany', () => {
it('throws not_member and never writes when the user lacks membership', async () => {
const { supabase, calls } = buildSupabase({
company_members: { single: { data: null, error: { message: 'no rows' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('not_member')
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('throws persist_failed and does NOT set the cookie when the upsert errors (#701)', async () => {
const { supabase } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: null, error: { message: 'permission denied' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('persist_failed')
expect(err.message).toContain('permission denied')
// The exact regression from #701: cookie must not diverge from the DB.
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('throws persist_failed when the read-back does not return the new company', async () => {
// An RLS-filtered UPDATE affects zero rows without an error; the
// read-back is what catches it. Simulate a stale/foreign row coming back.
const { supabase } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: { active_company_id: 'company-1' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('persist_failed')
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('sets the cookies only after the write is verified', async () => {
const { supabase, calls } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: { active_company_id: 'company-2' } } },
})
await expect(setActiveCompany(supabase as never, 'user-1', 'company-2')).resolves.toBeUndefined()
const upsert = calls.find((c) => c.table === 'user_preferences' && c.method === 'upsert')
expect(upsert?.args[0]).toEqual({ user_id: 'user-1', active_company_id: 'company-2' })
expect(mockCookieSet).toHaveBeenCalledTimes(2)
expect(mockCookieSet).toHaveBeenCalledWith(
'gnubok-company-id',
'company-2',
expect.objectContaining({ httpOnly: true, path: '/' }),
)
// The explicit-choice marker (byrå landing): must be a SESSION cookie
// (no maxAge), so a new browser session starts unpicked and byrå
// owners/admins land in the cockpit again.
const picked = mockCookieSet.mock.calls.find((c) => c[0] === 'gnubok-company-picked')
expect(picked?.[1]).toBe('1')
expect(picked?.[2]).toEqual(
expect.objectContaining({ httpOnly: true, path: '/', sameSite: 'lax' }),
)
expect(picked?.[2]).not.toHaveProperty('maxAge')
})
})
describe('getActiveCompanyId', () => {
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
// The parallel fallback fetch doubles as validation in the common
// single-company case: no second, sequential round trip.
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('validates a preference that differs from the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: { company_id: 'company-2' } }, // validation of the preference
],
},
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-2')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(2)
})
it('falls back to the first membership when the preference is stale', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership
{ data: null }, // validation: preference archived / membership gone
],
},
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the first membership when there is no preference row', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('returns null when the user has no non-archived memberships', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
})
// A failed query must throw, never read as "no companies": callers redirect
// the null state to the onboarding wizard, and a transient failure was
// enough to show onboarding to a fully onboarded user (issue #1053).
it('throws resolution_failed when the preferences query fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null, error: { message: 'fetch failed' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
it('throws resolution_failed when the membership query fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null, error: { message: 'timeout' } } },
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
it('throws instead of silently switching company when preference validation fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: null, error: { message: 'connection reset' } }, // validation FAILS
],
},
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
// Falling back to company-1 here would silently flip a consultant onto
// the wrong company's books.
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
})
describe('getActiveCompanyId via resolve_active_company RPC', () => {
it('resolves from the RPC in one call without touching any table', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
)
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company')
// The whole point of the RPC: zero PostgREST table round trips.
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns null from an RPC row with a null company_id (no companies) without table queries', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: null, locale: 'en', used_fallback: true }] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
expect(supabase.from).not.toHaveBeenCalled()
})
it('throws resolution_failed on a non-fallback RPC error instead of masking it', async () => {
const { supabase } = buildSupabase(
{},
{ data: null, error: { code: '57014', message: 'statement timeout' } },
)
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
expect(supabase.from).not.toHaveBeenCalled()
})
it('falls back to the query path on 42501 (service-role client lacks EXECUTE)', async () => {
// The mcp-oauth token route and the events route (API-key branch) call
// requireCompanyId with createServiceClientNoCookies(): EXECUTE is
// granted to `authenticated` only, so the RPC refuses with 42501 and the
// query path (filtered by the explicit userId param) must take over.
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: null, error: { code: '42501', message: 'permission denied for function' } },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the query path on zero RPC rows (NULL auth.uid(), service client)', async () => {
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: [] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
})
describe('multi-user seat gate', () => {
it('setActiveCompany refuses a switch into a company frozen for the member', async () => {
multiUserSeam.membershipActive.mockResolvedValue(false)
const { supabase, calls } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2', role: 'member' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('company_locked')
// The preference write must never land: a persisted frozen preference
// would silently bounce every later resolution.
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('getActiveCompanyId calls the GATED rpc when enforcement is on', async () => {
multiUserSeam.enforced = true
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company_gated', {
p_grace_days: 20,
})
})
it('gated query fallback resolves the first ACCESSIBLE membership (dormant skipped)', async () => {
multiUserSeam.enforced = true
const memberships = [
// Frozen for the user: non-owner and no grant rows will match below.
{ company_id: 'frozen-co', role: 'member', created_at: '2026-01-01', companies: { team_id: null } },
{ company_id: 'owned-co', role: 'owner', created_at: '2026-02-01', companies: { team_id: null } },
]
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: { active_company_id: 'frozen-co' } } },
// The gated path awaits the list query (no maybeSingle): seed the
// chain's `order` terminal.
company_members: { order: { data: memberships } },
// No multi_user grants at all -> frozen-co is frozen for the member
// (the shared rpc mock returns zero rows for company_multi_user_state
// too, so getMultiUserState falls through to this grants read).
capability_grants: { or: { data: [] } },
},
// Zero RPC rows = NULL auth.uid() (service-role client): routes to the
// GATED query path, unlike PGRST202 (migration absent), which fails open.
{ data: [] },
)
// Preference points at the frozen company: resolution must skip it and
// land on the owned company instead of locking the user out.
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('owned-co')
})
it('PGRST202 on the gated rpc fails OPEN via the ungated query path (deploy race)', async () => {
multiUserSeam.enforced = true
// Default rpcResult is PGRST202. Pre-migration there are no multi_user
// rows, so the gated path would freeze this non-owner: the fallback must
// be the UNGATED path and still resolve their membership.
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
company_settings: { maybeSingle: { data: { company_name: 'Ny Firma AB' } } },
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
})
const name = await getCompanyDisplayName(supabase as never, 'company-1')
expect(name).toBe('Ny Firma AB')
// companies.name is the frozen onboarding value: it must not be consulted
// when the user has set a current name in settings.
expect(calls.find((c) => c.table === 'companies')).toBeUndefined()
})
it('falls back to companies.name when company_settings has no row', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: null } },
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe(
'Aktiebolaget Grundstenen 000000',
)
})
it('falls back to companies.name when company_settings.company_name is empty', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: { company_name: '' } } },
companies: { maybeSingle: { data: { name: 'Bolaget AB' } } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe('Bolaget AB')
})
it('returns null when neither table resolves a name', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: null } },
companies: { maybeSingle: { data: null } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBeNull()
})
})