Files
accounted/lib/entitlements/__tests__/capability-grants-connector.pg.test.ts
T
cfce2de925 feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' (#1747)
* 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>
2026-08-31 17:55:29 +02:00

106 lines
4.4 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { getPool } from '../../../tests/pg/setup'
import { seedCompany } from '../../../tests/pg/fixtures'
import { CONNECTOR_CAPABILITIES, PAID_CAPABILITIES } from '../keys'
// pg-real coverage for migration 20260831170000 (capability_grants.source
// accepts 'connector') and the invariant that the trial-seed trigger never
// hands a hosted company a connector grant.
beforeEach(() => {
vi.clearAllMocks()
})
const future = () => new Date(Date.now() + 86_400_000).toISOString()
async function clearGrants(companyId: string): Promise<void> {
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [companyId])
}
async function rpc(companyId: string, key: string): Promise<boolean> {
const { rows } = await getPool().query<{ ok: boolean }>(
`SELECT public.company_has_capability($1, $2) AS ok`,
[companyId, key],
)
return rows[0].ok
}
describe('capability_grants.source = connector', () => {
it('accepts connector and still rejects an unknown source', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
await getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'connector', $2)`,
[companyId, future()],
)
expect(await rpc(companyId, 'bank_sync')).toBe(true)
await expect(
getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'bogus', $2)`,
[companyId, future()],
),
).rejects.toThrow(/capability_grants_source_check/)
})
it('upserts on the (scope, key, source) identity like the stripe writer does', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
const first = future()
const later = new Date(Date.now() + 2 * 86_400_000).toISOString()
for (const exp of [first, later]) {
await getPool().query(
`INSERT INTO public.capability_grants (company_id, team_id, capability_key, source, expires_at)
VALUES ($1, NULL, 'skatteverket', 'connector', $2)
ON CONFLICT (company_id, team_id, capability_key, source) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
[companyId, exp],
)
}
const { rows } = await getPool().query<{ n: string; expires_at: Date }>(
`SELECT count(*)::text AS n, max(expires_at) AS expires_at FROM public.capability_grants
WHERE company_id = $1 AND capability_key = 'skatteverket' AND source = 'connector'`,
[companyId],
)
expect(rows[0].n).toBe('1')
expect(new Date(rows[0].expires_at).toISOString()).toBe(later)
})
// Hosted companies must never be handed connector grants: the trial seed
// covers the PAID keys only, and the connector-only keys get nothing.
it('the trial-seed trigger writes no connector-source rows and nothing for connector-only keys', async () => {
const { companyId } = await seedCompany()
const { rows } = await getPool().query<{ capability_key: string; source: string }>(
`SELECT capability_key, source FROM public.capability_grants WHERE company_id = $1`,
[companyId],
)
expect(rows.some((r) => r.source === 'connector')).toBe(false)
const connectorOnly = CONNECTOR_CAPABILITIES.filter((k) => !PAID_CAPABILITIES.includes(k))
expect(connectorOnly.length).toBeGreaterThan(0)
for (const key of connectorOnly) {
expect(rows.some((r) => r.capability_key === key), `no trial grant for ${key}`).toBe(false)
}
})
// Migration 20260831180000: connector grants are a short-lived offline
// cache; a NULL expiry would be a permanent unlock nothing revokes.
it('rejects a connector grant without an expiry; other sources keep NULL expiry', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
await expect(
getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'connector', NULL)`,
[companyId],
),
).rejects.toThrow(/capability_grants_connector_expiry_check/)
await getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'manual', NULL)`,
[companyId],
)
expect(await rpc(companyId, 'bank_sync')).toBe(true)
})
})