The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the sidebar, command palette, and home "Att gora" list, its page directly reachable, and every non-AI HTTP route open. Its whole value is AI field extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint elsewhere, so gate the whole surface on CAPABILITY.ai. - EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the single source the nav item, the page, and the API dispatcher all read. - Hide the sidebar item, command-palette entry, and home inbox row for non-payers; subtract inbox_document from the "Att gora" total via one shared visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0. - Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState. - Enforce the capability in the extension API dispatcher (the single chokepoint that already enforces MFA), so every company-context inbox route 403s. The skipAuth /inbound webhook stays open (freeze-and-retain). - FORCE_PAYWALL=true override so the real gate is exercisable in local dev. - Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt, visibleWorklistTotal, and enable-banking /connect + /sync 403. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.9 KiB
TypeScript
171 lines
6.9 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
hasCapability,
|
|
requireCapability,
|
|
capabilityBlockedResponse,
|
|
} from '../has-capability'
|
|
import { CAPABILITY } 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('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('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)
|
|
})
|
|
})
|