fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924)
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a566a42aec
commit
19cbb0094b
@@ -0,0 +1,82 @@
|
||||
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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -17,6 +17,6 @@
|
||||
"hasOwnData": true,
|
||||
"readsCoreTables": ["document_attachments", "suppliers"],
|
||||
"description": "Vidarebefordra leverantörsfakturor till en unik adress: dokumenten landar här med extraherade fält",
|
||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt."
|
||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI. Kräver AI-funktionen i din prenumeration."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user