The dashboard layout runs on every hard load, hard refresh, company switch and the 16 router.refresh() sites, and loading.tsx cannot paint until it resolves. It cost ~20 network calls in 4 sequential waves: a third getUser() round trip to Supabase Auth (after the proxy's and the route guard's), the company resolution, then 16 reads including four limit-1 probes whose only job is to decide whether to render the Webshop and Körjournal nav rows, and an entitlements read that itself ran two waves. - lib/auth/claims.ts: claimsPinned/userFromClaims extracted from require-auth.ts (unchanged) so the dashboard request context shares the exact pinning + mapping. getDashboardAuthContext verifies the JWT locally and falls back to getUser() only when claims are missing, unpinned or unverifiable: the proxy already performed the per-request revocation check before the layout runs (same semantics approved for routes on 2026-07-23). - Wave 1 (user-keyed, parallel with the company resolution): team membership, profile, user preferences and the memberships join, which now also supplies the active company's row and role, so the separate companies and company_members reads are gone. - Wave 2 (company-keyed): settings, agent profile, the switcher's settings names, entitlements in ONE wave (getCompanyEntitlements takes the team_id the join already carries and runs the grants read alongside config + subscription), and get_dashboard_nav_flags(). - supabase/migrations/20260826120000_get_dashboard_nav_flags.sql: SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy ordering) and degrades to hidden rows on any other error. - tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs pending WooCommerce, active Shopify, mileage trips, RLS for a member of another company, EXECUTE grants. Unit tests for the wrapper (RPC row, single-object payload, each fallback code, other errors) and for the entitlements teamId option. ~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
hasCapability,
|
|
requireCapability,
|
|
capabilityBlockedResponse,
|
|
getCompanyIdsWithCapability,
|
|
getCompanyEntitlements,
|
|
} from '../has-capability'
|
|
import { CAPABILITY, PAID_CAPABILITIES } from '../keys'
|
|
|
|
/**
|
|
* Per-table mock: each table resolves to its own configured result, so a
|
|
* function that queries several tables in one call (companies → capability_grants
|
|
* → company_capability_config) gets the right answer per table. Any chained
|
|
* method returns the chain; awaiting it (or .maybeSingle()/.or()) resolves to
|
|
* the table's result.
|
|
*/
|
|
type TableResult = { data: unknown; error?: unknown }
|
|
function makeSupabase(byTable: Record<string, TableResult>): 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) } as unknown as SupabaseClient
|
|
}
|
|
|
|
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
describe('hasCapability', () => {
|
|
it('returns true on self-hosted without touching the DB', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({}) // would resolve to null/false if queried
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
|
})
|
|
|
|
it('development bypasses the gate (all-on) so gated features are testable without a subscription', async () => {
|
|
// This is WHY a lapsed company still sees paid surfaces under `npm run dev`.
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
const supabase = makeSupabase({}) // no grant: would be false if the gate ran
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
|
})
|
|
|
|
it('FORCE_PAYWALL=true activates the real gate in development (fail-closed on an expired grant)', async () => {
|
|
vi.stubEnv('NODE_ENV', 'development') // would otherwise bypass
|
|
vi.stubEnv('FORCE_PAYWALL', 'true')
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-60_000) }] }, // expired
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
|
})
|
|
|
|
it('FORCE_PAYWALL never overrides self-hosted (stays all-on)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
vi.stubEnv('FORCE_PAYWALL', 'true')
|
|
const supabase = makeSupabase({}) // would resolve null/false if queried
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
|
})
|
|
|
|
it('returns true for an unexpired company-scoped grant', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(60_000) }] },
|
|
company_capability_config: { data: null },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
|
})
|
|
|
|
it('treats a null expiry as never-expiring (true)', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: null }] },
|
|
company_capability_config: { data: null },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.bank_sync)).toBe(true)
|
|
})
|
|
|
|
it('fails closed when there is no grant', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
|
})
|
|
|
|
it('fails closed when the only grant is expired', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-60_000) }] },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
|
})
|
|
|
|
it('honours a firm/team-scoped grant (cascades to the client company)', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: '22222222-2222-4222-8222-222222222222' } },
|
|
capability_grants: { data: [{ expires_at: iso(60_000) }] }, // grant lives on the team
|
|
company_capability_config: { data: null },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.skatteverket)).toBe(true)
|
|
})
|
|
|
|
it('returns false when entitled but explicitly disabled (enablement axis)', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: null }] },
|
|
company_capability_config: { data: { enabled: false } },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
|
})
|
|
|
|
it('fails closed when the grants query errors', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: null, error: { message: 'boom' } },
|
|
})
|
|
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getCompanyIdsWithCapability', () => {
|
|
const directCompanyId = '11111111-1111-4111-8111-111111111111'
|
|
const firmCompanyId = '22222222-2222-4222-8222-222222222222'
|
|
const expiredCompanyId = '33333333-3333-4333-8333-333333333333'
|
|
const disabledCompanyId = '44444444-4444-4444-8444-444444444444'
|
|
const teamId = '55555555-5555-4555-8555-555555555555'
|
|
|
|
it('resolves direct and firm grants before excluding expired and disabled companies', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: {
|
|
data: [
|
|
{ id: directCompanyId, team_id: null },
|
|
{ id: firmCompanyId, team_id: teamId },
|
|
{ id: expiredCompanyId, team_id: null },
|
|
{ id: disabledCompanyId, team_id: null },
|
|
],
|
|
},
|
|
capability_grants: {
|
|
data: [
|
|
{ company_id: directCompanyId, team_id: null, expires_at: null },
|
|
{ company_id: null, team_id: teamId, expires_at: iso(60_000) },
|
|
{ company_id: expiredCompanyId, team_id: null, expires_at: iso(-60_000) },
|
|
{ company_id: disabledCompanyId, team_id: null, expires_at: null },
|
|
],
|
|
},
|
|
company_capability_config: { data: [{ company_id: disabledCompanyId }] },
|
|
})
|
|
|
|
const result = await getCompanyIdsWithCapability(
|
|
supabase,
|
|
[directCompanyId, firmCompanyId, expiredCompanyId, disabledCompanyId],
|
|
CAPABILITY.bank_sync,
|
|
)
|
|
|
|
expect([...result].sort()).toEqual([directCompanyId, firmCompanyId].sort())
|
|
})
|
|
|
|
it('returns every valid requested company when the paywall is bypassed', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({})
|
|
|
|
const result = await getCompanyIdsWithCapability(
|
|
supabase,
|
|
[directCompanyId, directCompanyId, 'not-a-uuid'],
|
|
CAPABILITY.skatteverket,
|
|
)
|
|
|
|
expect([...result]).toEqual([directCompanyId])
|
|
})
|
|
|
|
it('throws on a database error so a cron run cannot silently skip every payer', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: null, error: { message: 'connection reset' } },
|
|
company_capability_config: { data: [] },
|
|
})
|
|
|
|
await expect(
|
|
getCompanyIdsWithCapability(supabase, [directCompanyId], CAPABILITY.bank_sync),
|
|
).rejects.toThrow('Failed to resolve capability company scopes: connection reset')
|
|
})
|
|
})
|
|
|
|
describe('requireCapability', () => {
|
|
it('returns null (proceed) when the company has the capability', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: null }] },
|
|
company_capability_config: { data: null },
|
|
})
|
|
expect(await requireCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBeNull()
|
|
})
|
|
|
|
it('returns a 403 capability_blocked response when missing', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
})
|
|
const res = await requireCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)
|
|
expect(res).not.toBeNull()
|
|
expect(res!.status).toBe(403)
|
|
const body = await res!.json()
|
|
expect(body.capability_blocked).toBe(true)
|
|
expect(body.capability).toBe(CAPABILITY.ai)
|
|
})
|
|
})
|
|
|
|
describe('getCompanyEntitlements', () => {
|
|
const companyId = '11111111-1111-4111-8111-111111111111'
|
|
|
|
it('reports the trial expiry while the trial is the only source of access', async () => {
|
|
const expiry = iso(10 * 24 * 3600 * 1000)
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [
|
|
{ capability_key: CAPABILITY.ai, expires_at: expiry, source: 'trial' },
|
|
{ capability_key: CAPABILITY.bank_sync, expires_at: expiry, source: 'trial' },
|
|
],
|
|
},
|
|
company_capability_config: { data: [] },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.trialEndsAt).toBe(expiry)
|
|
expect(result.capabilities).toContain(CAPABILITY.ai)
|
|
expect(result.capabilities).toContain(CAPABILITY.bank_sync)
|
|
expect(result.entitlementState).toBe('trial')
|
|
expect(result.trialExpiredAt).toBeNull()
|
|
})
|
|
|
|
it('skips the companies lookup and still scopes grants to the team when teamId is supplied', async () => {
|
|
const teamId = '22222222-2222-4222-8222-222222222222'
|
|
const base = makeSupabase({
|
|
// Deliberately wrong: if the lookup ran, the team scope would be lost.
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [{ capability_key: CAPABILITY.ai, expires_at: null, source: 'stripe' }],
|
|
},
|
|
company_capability_config: { data: [] },
|
|
})
|
|
const tables: string[] = []
|
|
const supabase = {
|
|
from: (table: string) => {
|
|
tables.push(table)
|
|
return (base.from as (t: string) => unknown)(table)
|
|
},
|
|
} as unknown as SupabaseClient
|
|
const result = await getCompanyEntitlements(supabase, companyId, { teamId })
|
|
expect(result.capabilities).toContain(CAPABILITY.ai)
|
|
expect(result.entitlementState).toBe('paid')
|
|
expect(tables).not.toContain('companies')
|
|
expect(tables).toContain('capability_grants')
|
|
})
|
|
|
|
it('hides the trial once a non-trial grant is active (converted customer)', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [
|
|
{ capability_key: CAPABILITY.ai, expires_at: iso(10 * 24 * 3600 * 1000), source: 'trial' },
|
|
{ capability_key: CAPABILITY.ai, expires_at: null, source: 'stripe' },
|
|
],
|
|
},
|
|
company_capability_config: { data: [] },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.capabilities).toContain(CAPABILITY.ai)
|
|
expect(result.entitlementState).toBe('paid')
|
|
expect(result.trialExpiredAt).toBeNull()
|
|
})
|
|
|
|
it('reports trial_expired with the lapsed expiry after the trial lapsed', async () => {
|
|
const expiredEarlier = iso(-120_000)
|
|
const expiredLatest = iso(-60_000)
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [
|
|
{ capability_key: CAPABILITY.ai, expires_at: expiredLatest, source: 'trial' },
|
|
{ capability_key: CAPABILITY.bank_sync, expires_at: expiredEarlier, source: 'trial' },
|
|
],
|
|
},
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.capabilities).toEqual([])
|
|
expect(result.entitlementState).toBe('trial_expired')
|
|
// Latest expiry across the trial rows, even though all are expired.
|
|
expect(result.trialExpiredAt).toBe(expiredLatest)
|
|
})
|
|
|
|
it('reports lapsed_subscription for a churned payer (cancelled subscription, expired trial rows)', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [{ capability_key: CAPABILITY.ai, expires_at: iso(-60_000), source: 'trial' }],
|
|
},
|
|
company_subscriptions: { data: { status: 'canceled' } },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.entitlementState).toBe('lapsed_subscription')
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.capabilities).toEqual([])
|
|
})
|
|
|
|
it('a live subscription status never marks a company lapsed', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [{ capability_key: CAPABILITY.ai, expires_at: iso(-60_000), source: 'trial' }],
|
|
},
|
|
company_subscriptions: { data: { status: 'active' } },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.entitlementState).toBe('trial_expired')
|
|
})
|
|
|
|
it('reports none when no grant rows exist at all', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.entitlementState).toBe('none')
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.trialExpiredAt).toBeNull()
|
|
expect(result.capabilities).toEqual([])
|
|
})
|
|
|
|
it('bypass (self-hosted) holds everything with no trial countdown', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
|
|
expect(result.entitlementState).toBe('paid')
|
|
})
|
|
})
|
|
|
|
describe('capabilityBlockedResponse', () => {
|
|
it('returns a bilingual 403 carrying the capability key', async () => {
|
|
const res = capabilityBlockedResponse(CAPABILITY.bank_sync)
|
|
expect(res.status).toBe(403)
|
|
const body = await res.json()
|
|
expect(body.error).toBeTruthy()
|
|
expect(body.error_en).toBeTruthy()
|
|
expect(body.capability_blocked).toBe(true)
|
|
expect(body.capability).toBe(CAPABILITY.bank_sync)
|
|
})
|
|
})
|