cfce2de925
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(entitlements): self-host connector gate honors only source=connector grants The trial-seed trigger (seed_trial_capability_grants) writes 30-day source='trial' rows for bank_sync and skatteverket on every company insert, self-hosts included. The partitioned self-host gate read every active grant, so a fresh self-host company held every connector capability for a month with no connector key (CodeRabbit finding on #1747, verified against migration 20260818170000). hasCapability and getCompanyIdsWithCapability now add .eq('source', 'connector') on a self-host; getCompanyEntitlements skips non-connector rows there. Hosted reads every source exactly as before (covered by a test asserting no source filter is applied). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(entitlements): pin the early-grants wave on the self-host partition, fix stale docstrings Address the review round on the readGrants() merge resolution: - Add two recording-mock tests for getCompanyEntitlements with teamId (the dashboard layout path): on a self-host the early grants read must narrow to the connector keys and carry source = 'connector'; on hosted it reads every paid key with no source filter. The in-loop source check masked a lost narrowing, so this pins the query itself. - Move the getCompanyEntitlements docstring back above the function and replace "Self-hosted holds everything" with the actual partition. - keys.ts: say that lib/connect/instance arrives with stack PR #1748 so a reader on main does not chase a module that is not there yet. - DECISIONS.md: describe the InvoiceInboxWorkspace.tsx merge resolution accurately (set to the #1753 blob origin/main carried at push time, not the merged parent's) and note that stack children must merge this branch forward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NdxH4DGnggvqHBCmif6B16 * chore(connector): merge origin/main, re-version connector migration to 20260831170000 Migration 20260820122000 predates versions already applied to prod (latest 20260831150000); renamed to keep Supabase branching history monotonic. Test reference updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): own-credentials seam so self-hosts running their own EB/SKV are never connector-gated Forward-ports the connector-mode seam's own-credentials half from the instance-wiring layer: a self-host with its own Enable Banking or Skatteverket credentials holds that capability outright, exactly like every other local capability. Without this, upgrading an own-credentials self-host silently killed working bank sync and SKV integrations and showed a hosted subscription upsell whose remedy does not exist for a self-host. capability_blocked copy gains a self-host variant naming GNUBOK_CONNECTOR_KEY; stale all-on comments updated at the two gate call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): route the pending-op capability block through capabilityBlockedError for self-host copy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): PR #1747 review batch: connector-grant expiry CHECK, test cleanup, DECISIONS correction - Migration 20260831180000: CHECK (source <> 'connector' OR expires_at IS NOT NULL); connector grants are a short-lived offline cache, a NULL expiry would be a permanent unlock nothing revokes. pg test added. - beforeEach vi.clearAllMocks() in the three entitlements test files. - DECISIONS entry corrected: the trial seed DOES write trial rows for bank_sync/skatteverket; what it never writes is source='connector' or the connector-only keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
816 lines
34 KiB
TypeScript
816 lines
34 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
hasCapability,
|
|
requireCapability,
|
|
capabilityBlockedResponse,
|
|
getCompanyIdsWithCapability,
|
|
getCompanyEntitlements,
|
|
} from '../has-capability'
|
|
import { CAPABILITY, CONNECTOR_CAPABILITIES, 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
|
|
}
|
|
|
|
/**
|
|
* Same per-table mock, but every chained call is recorded so a test can
|
|
* assert WHICH filters a query applied (the mock itself ignores them).
|
|
*/
|
|
type RecordedCall = { table: string; method: string; args: unknown[] }
|
|
function makeRecordingSupabase(byTable: Record<string, TableResult>, calls: RecordedCall[]): 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 (...args: unknown[]) => {
|
|
calls.push({ table, method: String(prop), args })
|
|
return chain
|
|
}
|
|
},
|
|
},
|
|
)
|
|
return chain
|
|
}
|
|
return { from: (t: string) => chainFor(t) } as unknown as SupabaseClient
|
|
}
|
|
const sourceFilters = (calls: RecordedCall[]) =>
|
|
calls.filter((c) => c.table === 'capability_grants' && c.method === 'eq' && c.args[0] === 'source')
|
|
|
|
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
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 (self-hosted, local capability)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({})
|
|
|
|
// A local capability: self-hosted is all-on without touching the DB. The
|
|
// connector capabilities (e.g. skatteverket) are covered in the
|
|
// self-hosted connector block below: they need a grant even here.
|
|
const result = await getCompanyIdsWithCapability(
|
|
supabase,
|
|
[directCompanyId, directCompanyId, 'not-a-uuid'],
|
|
CAPABILITY.ai,
|
|
)
|
|
|
|
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('self-hosted holds every local paid capability with no trial countdown; connector keys need a grant', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
company_capability_config: { data: [] },
|
|
})
|
|
const result = await getCompanyEntitlements(supabase, companyId)
|
|
expect(result.trialEndsAt).toBeNull()
|
|
expect(result.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k)))
|
|
// No trial exists on a self-host: 'none' until a connector grant is active.
|
|
expect(result.entitlementState).toBe('none')
|
|
})
|
|
|
|
it('dev bypass on a self-host still holds everything (connector keys included)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
const result = await getCompanyEntitlements(makeSupabase({}), companyId)
|
|
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)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Sovereign self-host partition (plan WS3 PR3). Local capabilities stay all-on
|
|
* on a self-host; the CONNECTOR_CAPABILITIES (bank sync, Skatteverket, org
|
|
* lookup, migration: services Accounted operates) fall through to the grant
|
|
* lookup, where the connector sync writes source='connector' rows. Hosted
|
|
* behaviour is untouched by construction (the tests above still pass).
|
|
*/
|
|
describe('self-hosted connector capabilities', () => {
|
|
const COMPANY = '11111111-1111-4111-8111-111111111111'
|
|
|
|
it('keeps every local capability all-on without touching the DB', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({})
|
|
for (const key of PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))) {
|
|
expect(await hasCapability(supabase, COMPANY, key), key).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('gates a connector capability on a grant (true with an active connector grant)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
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, COMPANY, CAPABILITY.bank_sync)).toBe(true)
|
|
})
|
|
|
|
it('fails closed for a connector capability without a grant, and once the grant expired (offline grace over)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
expect(
|
|
await hasCapability(
|
|
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } }),
|
|
COMPANY,
|
|
CAPABILITY.skatteverket,
|
|
),
|
|
).toBe(false)
|
|
expect(
|
|
await hasCapability(
|
|
makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-60_000) }] },
|
|
}),
|
|
COMPANY,
|
|
CAPABILITY.org_lookup,
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('keeps the dev bypass all-on on a self-host, connector capabilities included', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.migration)).toBe(true)
|
|
})
|
|
|
|
it('lets FORCE_PAYWALL run the real gate for connector capabilities but never for local ones', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
vi.stubEnv('FORCE_PAYWALL', 'true')
|
|
const noGrant = makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } })
|
|
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.bank_sync)).toBe(false)
|
|
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.ai)).toBe(true)
|
|
})
|
|
|
|
it('bulk resolution honours the same partition', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({
|
|
companies: { data: [{ id: COMPANY, team_id: null }] },
|
|
company_capability_config: { data: [] },
|
|
capability_grants: { data: [] },
|
|
})
|
|
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.ai)).toEqual(new Set([COMPANY]))
|
|
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.bank_sync)).toEqual(new Set())
|
|
})
|
|
|
|
it('getCompanyEntitlements reports local paid keys plus active connector keys, state paid/none, no trial copy', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const localPaid = PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))
|
|
|
|
const without = await getCompanyEntitlements(
|
|
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] }, company_capability_config: { data: [] } }),
|
|
COMPANY,
|
|
)
|
|
expect(without.capabilities).toEqual(localPaid)
|
|
expect(without.entitlementState).toBe('none')
|
|
expect(without.trialEndsAt).toBeNull()
|
|
expect(without.trialExpiredAt).toBeNull()
|
|
|
|
const withGrant = await getCompanyEntitlements(
|
|
makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'connector' }] },
|
|
company_capability_config: { data: [] },
|
|
}),
|
|
COMPANY,
|
|
)
|
|
expect(withGrant.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => localPaid.includes(k) || k === CAPABILITY.bank_sync))
|
|
expect(withGrant.entitlementState).toBe('paid')
|
|
})
|
|
|
|
// The dashboard layout passes teamId, which moves the grants read into the
|
|
// early wave (#1946). That call site and the fallback read must narrow to
|
|
// the same key list and carry the same source filter, or the self-host
|
|
// partition silently reads every paid key on the layout path. The in-loop
|
|
// source check masks a lost narrowing, so this pins the query itself.
|
|
it('getCompanyEntitlements with teamId narrows the early grants read to connector keys and source=connector on a self-host', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const calls: RecordedCall[] = []
|
|
const supabase = makeRecordingSupabase(
|
|
{
|
|
// Deliberately wrong: the lookup must not run when teamId is supplied.
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
company_capability_config: { data: [] },
|
|
company_subscriptions: { data: null },
|
|
},
|
|
calls,
|
|
)
|
|
const result = await getCompanyEntitlements(supabase, COMPANY, { teamId: null })
|
|
const keyFilters = calls.filter((c) => c.table === 'capability_grants' && c.method === 'in')
|
|
expect(keyFilters.map((c) => c.args)).toEqual([
|
|
['capability_key', PAID_CAPABILITIES.filter((k) => CONNECTOR_CAPABILITIES.includes(k))],
|
|
])
|
|
expect(sourceFilters(calls).map((c) => c.args)).toEqual([['source', 'connector']])
|
|
expect(calls.map((c) => c.table)).not.toContain('companies')
|
|
expect(result.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k)))
|
|
expect(result.entitlementState).toBe('none')
|
|
})
|
|
|
|
it('getCompanyEntitlements with teamId reads every paid key and every source on hosted', async () => {
|
|
const TEAM = '55555555-5555-4555-8555-555555555555'
|
|
const calls: RecordedCall[] = []
|
|
const supabase = makeRecordingSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
company_capability_config: { data: [] },
|
|
company_subscriptions: { data: null },
|
|
},
|
|
calls,
|
|
)
|
|
await getCompanyEntitlements(supabase, COMPANY, { teamId: TEAM })
|
|
const keyFilters = calls.filter((c) => c.table === 'capability_grants' && c.method === 'in')
|
|
expect(keyFilters.map((c) => c.args)).toEqual([['capability_key', PAID_CAPABILITIES]])
|
|
expect(sourceFilters(calls)).toHaveLength(0)
|
|
expect(calls.map((c) => c.table)).not.toContain('companies')
|
|
})
|
|
|
|
// The trial-seed trigger writes source='trial' rows for bank_sync and
|
|
// skatteverket on every company insert, self-hosts included. Only the
|
|
// connector sync's own rows may unlock a connector capability there.
|
|
it('reads only source=connector grants on a self-host, so the trial seed cannot unlock a connector', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const calls: RecordedCall[] = []
|
|
const supabase = makeRecordingSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(60_000) }] },
|
|
company_capability_config: { data: null },
|
|
},
|
|
calls,
|
|
)
|
|
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
|
|
expect(sourceFilters(calls).map((c) => c.args)).toEqual([['source', 'connector']])
|
|
})
|
|
|
|
it('bulk resolution applies the source=connector filter to both the company and the firm grant reads', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const TEAM = '55555555-5555-4555-8555-555555555555'
|
|
const calls: RecordedCall[] = []
|
|
const supabase = makeRecordingSupabase(
|
|
{
|
|
companies: { data: [{ id: COMPANY, team_id: TEAM }] },
|
|
company_capability_config: { data: [] },
|
|
capability_grants: { data: [] },
|
|
},
|
|
calls,
|
|
)
|
|
await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.skatteverket)
|
|
expect(sourceFilters(calls).map((c) => c.args)).toEqual([
|
|
['source', 'connector'],
|
|
['source', 'connector'],
|
|
])
|
|
})
|
|
|
|
it('hosted keeps reading grants of every source (trial, stripe, comp, manual): no connector filter', async () => {
|
|
const calls: RecordedCall[] = []
|
|
const supabase = makeRecordingSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(60_000) }] },
|
|
company_capability_config: { data: null },
|
|
},
|
|
calls,
|
|
)
|
|
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
|
|
expect(sourceFilters(calls)).toHaveLength(0)
|
|
|
|
const bulkCalls: RecordedCall[] = []
|
|
await getCompanyIdsWithCapability(
|
|
makeRecordingSupabase(
|
|
{
|
|
companies: { data: [{ id: COMPANY, team_id: null }] },
|
|
company_capability_config: { data: [] },
|
|
capability_grants: { data: [] },
|
|
},
|
|
bulkCalls,
|
|
),
|
|
[COMPANY],
|
|
CAPABILITY.bank_sync,
|
|
)
|
|
expect(sourceFilters(bulkCalls)).toHaveLength(0)
|
|
})
|
|
|
|
it('getCompanyEntitlements ignores trial-seeded connector rows on a self-host', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const localPaid = PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))
|
|
|
|
const seededOnly = await getCompanyEntitlements(
|
|
makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [
|
|
{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'trial' },
|
|
{ capability_key: 'skatteverket', expires_at: iso(60_000), source: 'trial' },
|
|
],
|
|
},
|
|
company_capability_config: { data: [] },
|
|
}),
|
|
COMPANY,
|
|
)
|
|
expect(seededOnly.capabilities).toEqual(localPaid)
|
|
expect(seededOnly.entitlementState).toBe('none')
|
|
expect(seededOnly.trialEndsAt).toBeNull()
|
|
expect(seededOnly.trialExpiredAt).toBeNull()
|
|
|
|
const mixed = await getCompanyEntitlements(
|
|
makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: {
|
|
data: [
|
|
{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'trial' },
|
|
{ capability_key: 'skatteverket', expires_at: iso(60_000), source: 'connector' },
|
|
],
|
|
},
|
|
company_capability_config: { data: [] },
|
|
}),
|
|
COMPANY,
|
|
)
|
|
expect(mixed.capabilities).toEqual(
|
|
PAID_CAPABILITIES.filter((k) => localPaid.includes(k) || k === CAPABILITY.skatteverket),
|
|
)
|
|
expect(mixed.entitlementState).toBe('paid')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Own-credentials seam: a self-host that serves an upstream from its OWN
|
|
* credentials (its own Enable Banking registration, its own Skatteverket
|
|
* client) is never connector-gated for it. Without this, upgrading an
|
|
* own-credentials self-host would silently kill working bank/SKV integrations
|
|
* (the 2026-08-17 folded-flag incident, recreated). Hosted never reaches the
|
|
* seam: isBypassedFor checks isSelfHosted() first.
|
|
*/
|
|
describe('self-hosted own-credentials seam', () => {
|
|
const COMPANY = '11111111-1111-4111-8111-111111111111'
|
|
const CRED_VARS = [
|
|
'ENABLE_BANKING_APP_ID',
|
|
'ENABLE_BANKING_APP_ID_PRODUCTION',
|
|
'ENABLE_BANKING_PRIVATE_KEY',
|
|
'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION',
|
|
'SKATTEVERKET_OAUTH2_CLIENT_ID',
|
|
'SKATTEVERKET_APIGW_CLIENT_ID',
|
|
] as const
|
|
const stubNoOwnCredentials = () => {
|
|
for (const v of CRED_VARS) vi.stubEnv(v, '')
|
|
}
|
|
|
|
it('own EB credentials keep bank_sync all-on without touching the DB (FORCE_PAYWALL included)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
vi.stubEnv('FORCE_PAYWALL', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
|
const supabase = makeSupabase({}) // would resolve null/false if queried
|
|
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
|
|
// skatteverket has no own credentials here: still gated.
|
|
expect(
|
|
await hasCapability(
|
|
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } }),
|
|
COMPANY,
|
|
CAPABILITY.skatteverket,
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('own SKV credentials keep skatteverket all-on (either client id variant)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
|
|
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.skatteverket)).toBe(true)
|
|
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', '')
|
|
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'own-oauth-client')
|
|
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.skatteverket)).toBe(true)
|
|
})
|
|
|
|
it('own credentials never unlock org_lookup or migration (no own-credentials form exists)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
|
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
|
|
const noGrant = makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } })
|
|
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.org_lookup)).toBe(false)
|
|
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.migration)).toBe(false)
|
|
})
|
|
|
|
it('hosted behaviour is unchanged by credential env vars (gate still runs)', async () => {
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'hosted-always-has-these')
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-60_000) }] }, // expired
|
|
})
|
|
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(false)
|
|
})
|
|
|
|
it('bulk resolution honours the seam', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
|
const supabase = makeSupabase({
|
|
companies: { data: [{ id: COMPANY, team_id: null }] },
|
|
company_capability_config: { data: [] },
|
|
capability_grants: { data: [] },
|
|
})
|
|
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.bank_sync)).toEqual(new Set([COMPANY]))
|
|
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.skatteverket)).toEqual(new Set())
|
|
})
|
|
|
|
it('getCompanyEntitlements counts own-credential keys as local paid and narrows the grants read', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
|
const calls: RecordedCall[] = []
|
|
const result = await getCompanyEntitlements(
|
|
makeRecordingSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
company_capability_config: { data: [] },
|
|
company_subscriptions: { data: null },
|
|
},
|
|
calls,
|
|
),
|
|
COMPANY,
|
|
)
|
|
expect(result.capabilities).toContain(CAPABILITY.bank_sync)
|
|
expect(result.capabilities).not.toContain(CAPABILITY.skatteverket)
|
|
expect(result.entitlementState).toBe('none') // no connector grant; touchpoint renders nothing
|
|
const grantKeyFilters = calls.filter(
|
|
(c) => c.table === 'capability_grants' && c.method === 'in' && c.args[0] === 'capability_key',
|
|
)
|
|
expect(grantKeyFilters).toHaveLength(1)
|
|
expect(grantKeyFilters[0].args[1]).toEqual([CAPABILITY.skatteverket])
|
|
})
|
|
|
|
it('getCompanyEntitlements skips the grants read entirely when every connector upstream has own credentials', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
stubNoOwnCredentials()
|
|
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
|
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
|
|
const calls: RecordedCall[] = []
|
|
const result = await getCompanyEntitlements(
|
|
makeRecordingSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
company_capability_config: { data: [] },
|
|
company_subscriptions: { data: null },
|
|
},
|
|
calls,
|
|
),
|
|
COMPANY,
|
|
{ teamId: null },
|
|
)
|
|
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
|
|
expect(calls.filter((c) => c.table === 'capability_grants')).toHaveLength(0)
|
|
})
|
|
|
|
it('self-host block copy names the connector key, hosted copy keeps the subscription upsell', async () => {
|
|
stubNoOwnCredentials()
|
|
const hosted = await capabilityBlockedResponse(CAPABILITY.bank_sync).json()
|
|
expect(hosted.error).toContain('prenumeration')
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const selfHost = await capabilityBlockedResponse(CAPABILITY.bank_sync).json()
|
|
expect(selfHost.error).toContain('GNUBOK_CONNECTOR_KEY')
|
|
expect(selfHost.error_en).toContain('GNUBOK_CONNECTOR_KEY')
|
|
})
|
|
})
|