19cbb0094b
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>
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Force the gate to run (no dev bypass) but stub requireCapability so we control
|
|
// entitlement per test. Mirrors the skatteverket capability-gate suite.
|
|
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
|
return { ...actual, requireCapability: vi.fn() }
|
|
})
|
|
|
|
import { enableBankingExtension } from '../index'
|
|
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
// The interactive bank-sync entry points gated on CAPABILITY.bank_sync.
|
|
const GATED: Array<{ method: string; path: string }> = [
|
|
{ method: 'POST', path: '/connect' },
|
|
{ method: 'POST', path: '/sync' },
|
|
]
|
|
|
|
function makeContext(): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'enable-banking',
|
|
requestId: 'req_test',
|
|
supabase: {
|
|
auth: {
|
|
getUser: vi
|
|
.fn()
|
|
.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
|
|
},
|
|
from: vi.fn(),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any,
|
|
emit: vi.fn().mockResolvedValue(undefined),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
|
|
settings: {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
clear: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
describe('enable-banking paywall gate', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it.each(GATED)(
|
|
'$method $path returns 403 capability_blocked when not entitled',
|
|
async ({ method, path }) => {
|
|
vi.mocked(requireCapability).mockResolvedValue(
|
|
capabilityBlockedResponse(CAPABILITY.bank_sync),
|
|
)
|
|
const route = enableBankingExtension.apiRoutes?.find(
|
|
(r) => r.method === method && r.path === path,
|
|
)
|
|
expect(route, `${method} ${path} must be registered`).toBeDefined()
|
|
|
|
const request = new Request(
|
|
`https://test.local/api/extensions/ext/enable-banking${path}`,
|
|
{
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
},
|
|
)
|
|
const response = await route!.handler(request, makeContext())
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = (await response.json()) as {
|
|
capability_blocked?: boolean
|
|
capability?: string
|
|
}
|
|
expect(body.capability_blocked).toBe(true)
|
|
expect(body.capability).toBe(CAPABILITY.bank_sync)
|
|
},
|
|
)
|
|
})
|