Files
accounted/lib/auth/__tests__/api-keys.test.ts
T
Jakob Wennberg 9fe37b85b5 feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended

An API key gets an optional ceiling in SEK. Above it the agent may still stage
the work, it just may not finish it alone: a human approves the same verifikat
in the app. Default is NULL, so every existing key keeps its behaviour and
turning this on is entirely opt-in.

Enforced at the two places an API key reaches the ledger, and at both the
refusal happens BEFORE the point of no return:

- MCP: in commitPendingOperation, before the atomic claim, so the operation
  stays 'pending'. Behind the claim it would be caught by the generic handler,
  marked terminal 'rejected', and the staged verifikat would be gone.
- REST: in journal-entries.commit, before commitEntry, so the draft stays a
  draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run
  refuses too, rather than promising a voucher number the key cannot deliver.

Not enforced inside commit_journal_entry: a RAISE there is swallowed by
engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function
that issues every voucher number.

Operations whose amount is only known during dispatch (batch allocation, bulk
booking, the settlement link paths) fail OPEN behind an explicit allowlist.
Pricing them ahead of dispatch would be a guess, and a wrong guess silently
breaks batch allocation the day someone sets a limit. The allowlist is derived
from what production actually stores: create_voucher carries total_debit on
1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003,
create_supplier_invoice_from_inbox carries total on 208 of 228.

This is a blast-radius cap, not a security boundary. A per-entry ceiling is
defeated by splitting one entry into several, and an LLM will find that, so
UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is
one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the
primitive that actually bounds exposure and is left to a separate change.

The guard is written NULL-first everywhere. An absent, unparseable or
non-positive ceiling always means unlimited, never "block everything".

Agents read their own ceiling from gnubok_get_agent_briefing instead of
discovering it by burning a staged verifikat on a 403.

Changing a ceiling is auditable: it now renders in behandlingshistorik
(BFL 5 kap. 11 §). The audit trigger already fired on the column, but the
report dropped the event because the field was not in its diff map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(skill): regenerate accounted-api skill for the new commit pitfall

apiskill:check is a ratchet: the generated reference must match the endpoint
registry. Never hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(agents): pin the DB default itself, and declare the briefing field required

Two review findings, both real:

- the default test stored an explicit NULL, so it stayed green even if the
  column default changed to a positive ceiling: the one change that would
  silently start blocking every existing key. It now omits the column.
- gnubok_get_agent_briefing documents unattended_commit_limit as always
  present and emits it unconditionally, so it belongs in the output schema's
  required list.

Declined the NOT VALID constraint suggestion, with the reason recorded in the
migration: api_keys is 388 rows / 768 kB in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): name the TOCTOU window in the REST ceiling check

A security scan flagged that the line sum is read before commitEntry, so a
concurrent write to the draft's lines can post over the ceiling. Real, and
accepted: closing it means enforcing inside commit_journal_entry, where a RAISE
becomes a retryable 500 and destroys the staged operation on the MCP path.

Recorded in the code rather than left implicit, so nobody later mistakes this
for a hard control. A per-entry ceiling is already defeated by splitting, which
needs no race; the cumulative rolling-window limit is the primitive that bounds
exposure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(agents): price the settlement and batch paths that were bypassing the ceiling

A security scan flagged that known money-posting operations fail open, and it
was right. The first cut priced only create_voucher, categorize_transaction and
create_supplier_invoice_from_inbox, on the belief that the batch and settlement
paths computed their totals only inside SQL at dispatch. Production says
otherwise: the staged preview already carries the amount, because it is the
number a human is shown when approving the operation.

Over the last 120 days each of these is present and numeric on 100% of that
type's staged rows:

  link_transaction_journal_entry  transaction_amount  1369 rows
  bulk_book_transactions          tx_sum               273 rows
  link_supplier_invoice_voucher   payment_amount        55 rows
  match_batch_allocate            total_allocated       24 rows
  mark_invoice_paid               total                  3 rows

So a key with a ceiling could post any amount through the four largest
settlement paths. Now priced, and the ceiling applies.

Only reconciliation_match stays unpriced: it carries pair_count, which is a
COUNT. Pricing off that would compare pairs against kronor, which is worse than
not enforcing. link_document_to_voucher and attach_document_to_transaction move
no money at all; the transaction_amount they carry is context, not a posting.

Genuinely unpriceable types still fail OPEN. This control can only ever narrow
what a key does, and a wrong guess at an amount blocks a legitimate commit, so
guessing high would leave an agent unable to work.

Adds a test that walks the whole allowlist, so a typo'd field name cannot
silently make a type unpriceable again: that is exactly the hole this closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room

The tools/list context-budget bench sits at 65 000 tokens and main now leaves
roughly 20 tokens of headroom. An always-present field on the briefing's output
schema costs about 85, so this addition alone pushed the bench red.

The bench's own note is explicit that the answer is to demote a tool rather than
raise the ceiling, so raising it here would be the wrong trade for a
nice-to-have.

Nothing is lost that matters: the operation is never destroyed when it is
refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs
one round trip and no work. That error already carries both attempted and limit,
and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing
is worth doing once there is budget to spend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(api): spell affärshändelse correctly in the commit pitfall

Fixed in the route's registerEndpoint pitfalls, which is the source; the skill
reference is regenerated from it and never hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:53:53 +01:00

463 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
const contextMocks = vi.hoisted(() => ({
getActiveCompanyId: vi.fn(),
}))
vi.mock('@/lib/company/active-company', () => ({
getActiveCompanyId: (...args: unknown[]) => contextMocks.getActiveCompanyId(...args),
}))
import {
generateApiKey,
hashApiKey,
extractBearerToken,
validateScopes,
hasScope,
validateApiKey,
findStageApproveConflict,
DEFAULT_SCOPES,
DEFAULT_OAUTH_SCOPES,
STAGING_SCOPES,
TOOL_SCOPE_MAP,
API_KEY_SCOPES,
} from '../api-keys'
import { createClient } from '@supabase/supabase-js'
const mockCreateClient = vi.mocked(createClient)
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// generateApiKey
// ============================================================
describe('generateApiKey', () => {
it('returns key starting with "gnubok_sk_"', () => {
const { key } = generateApiKey()
expect(key.startsWith('gnubok_sk_')).toBe(true)
})
it('returns 64-char hex SHA-256 hash', () => {
const { hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('returns prefix of KEY_PREFIX + 8 chars', () => {
const { key, prefix } = generateApiKey()
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
})
it('generates unique keys on successive calls', () => {
const a = generateApiKey()
const b = generateApiKey()
expect(a.key).not.toBe(b.key)
expect(a.hash).not.toBe(b.hash)
})
it('hash matches hashApiKey(key)', () => {
const { key, hash } = generateApiKey()
expect(hashApiKey(key)).toBe(hash)
})
})
// ============================================================
// hashApiKey
// ============================================================
describe('hashApiKey', () => {
it('returns 64-char hex string', () => {
const hash = hashApiKey('gnubok_sk_test-key')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashApiKey('gnubok_sk_deterministic')
const hash2 = hashApiKey('gnubok_sk_deterministic')
expect(hash1).toBe(hash2)
})
it('produces different hashes for different inputs', () => {
const hash1 = hashApiKey('gnubok_sk_key-a')
const hash2 = hashApiKey('gnubok_sk_key-b')
expect(hash1).not.toBe(hash2)
})
})
// ============================================================
// extractBearerToken
// ============================================================
describe('extractBearerToken', () => {
it('extracts token from valid Bearer header', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer my-secret-token' },
})
expect(extractBearerToken(request)).toBe('my-secret-token')
})
it('returns null when no authorization header', () => {
const request = new Request('http://localhost')
expect(extractBearerToken(request)).toBeNull()
})
it('returns null when header is not Bearer scheme', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Basic dXNlcjpwYXNz' },
})
expect(extractBearerToken(request)).toBeNull()
})
it('handles token with special characters', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
})
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
})
})
// ============================================================
// validateScopes
// ============================================================
describe('validateScopes', () => {
it('returns null for null input', () => {
expect(validateScopes(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(validateScopes(undefined)).toBeNull()
})
it('returns null for non-array input', () => {
expect(validateScopes('transactions:read')).toBeNull()
expect(validateScopes(42)).toBeNull()
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
})
it('filters to only valid API_KEY_SCOPES', () => {
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
expect(result).toEqual(['transactions:read', 'reports:read'])
})
it('returns null when no valid scopes remain after filter', () => {
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
})
it('preserves valid scopes from mixed input', () => {
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
expect(result).toEqual(['customers:write', 'invoices:read'])
})
})
// ============================================================
// hasScope
// ============================================================
describe('hasScope', () => {
it('returns true when scope present in array', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
})
it('returns false when scope absent', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
})
})
// ============================================================
// findStageApproveConflict
// ============================================================
describe('findStageApproveConflict', () => {
it('returns null when approve scope is absent', () => {
expect(findStageApproveConflict(['invoices:write', 'reports:read'])).toBeNull()
})
it('returns null when approve scope present but no staging scope', () => {
expect(
findStageApproveConflict(['pending_operations:approve', 'reports:read']),
).toBeNull()
})
it('returns the offending staging scope when both are present', () => {
expect(
findStageApproveConflict(['invoices:write', 'pending_operations:approve']),
).toBe('invoices:write')
})
it('treats every STAGING_SCOPES member as a conflict alongside approve', () => {
for (const staging of STAGING_SCOPES) {
expect(findStageApproveConflict([staging, 'pending_operations:approve'])).toBe(staging)
}
})
it('does NOT treat agent:write as a staging scope', () => {
expect(STAGING_SCOPES).not.toContain('agent:write')
// agent:write + approve is not a SoD conflict: memory writes don't stage
// bookkeeping that approve would commit.
expect(
findStageApproveConflict(['agent:write', 'pending_operations:approve']),
).toBeNull()
})
})
// ============================================================
// agent:write scope wiring
// ============================================================
describe('agent:write scope', () => {
it('is a registered scope with a Swedish label and description', () => {
expect(API_KEY_SCOPES['agent:write']).toBeDefined()
expect(API_KEY_SCOPES['agent:write'].label).toBe('Agent: skriv')
expect(typeof API_KEY_SCOPES['agent:write'].description).toBe('string')
})
it('maps the memory write tools to agent:write', () => {
expect(TOOL_SCOPE_MAP.gnubok_remember_fact).toBe('agent:write')
expect(TOOL_SCOPE_MAP.gnubok_forget_fact).toBe('agent:write')
})
it('keeps gnubok_get_agent_briefing on agent:read', () => {
expect(TOOL_SCOPE_MAP.gnubok_get_agent_briefing).toBe('agent:read')
})
it('is excluded from the default scope grants', () => {
expect(DEFAULT_SCOPES).not.toContain('agent:write')
expect(DEFAULT_OAUTH_SCOPES).not.toContain('agent:write')
})
})
// ============================================================
// validateApiKey
// ============================================================
describe('validateApiKey', () => {
function setupMockRpc(response: { data: unknown; error: unknown }) {
const mockRpc = vi.fn().mockResolvedValue(response)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
}
it('rejects keys not starting with "gnubok_sk_"', async () => {
const result = await validateApiKey('invalid-key-format')
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
})
it('rejects a refresh token presented as Bearer with a specific message', async () => {
const result = await validateApiKey('gnubok_rt_some_refresh_token')
expect('status' in result && result.status).toBe(401)
expect('error' in result && result.error).toContain('Refresh token')
})
it('rejects when RPC returns error', async () => {
setupMockRpc({ data: null, error: { message: 'db error' } })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('rejects when RPC returns empty data array', async () => {
setupMockRpc({ data: [], error: null })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('returns rate limit error when rate_limited is true', async () => {
setupMockRpc({
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
})
it('returns userId, companyId, scopes on success', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read', 'reports:read'],
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: undefined,
apiKeyName: undefined,
scopes: ['transactions:read', 'reports:read'],
mode: 'live',
unattendedCommitLimit: null,
})
})
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: null,
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: undefined,
apiKeyName: undefined,
scopes: DEFAULT_SCOPES,
mode: 'live',
unattendedCommitLimit: null,
})
})
it('surfaces mode from the RPC row', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
api_key_id: 'ak_1',
api_key_name: 'CI test key',
scopes: ['transactions:read'],
rate_limited: false,
mode: 'test',
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: 'ak_1',
apiKeyName: 'CI test key',
scopes: ['transactions:read'],
mode: 'test',
unattendedCommitLimit: null,
})
})
describe('unattended commit limit', () => {
it('surfaces a positive ceiling from the RPC row', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read'],
rate_limited: false,
// numeric(14,2) comes back from PostgREST as a string.
unattended_commit_limit: '25000.00',
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toMatchObject({ unattendedCommitLimit: 25000 })
})
it('reads anything that is not a positive number as no ceiling', async () => {
// The whole guard is NULL-first: an unparseable or non-positive value
// must mean "unlimited", never "block every commit this key attempts".
for (const raw of [null, undefined, 0, -1, 'abc', {}]) {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read'],
rate_limited: false,
unattended_commit_limit: raw,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toMatchObject({ unattendedCommitLimit: null })
}
})
})
describe('unbound keys (minted before the first company existed, issue #1814)', () => {
function setupUnboundKeyClient(rpcRow: Record<string, unknown>) {
const chain = {
update: vi.fn(),
eq: vi.fn(),
is: vi.fn().mockResolvedValue({ data: null, error: null }),
}
chain.update.mockReturnValue(chain)
chain.eq.mockReturnValue(chain)
const from = vi.fn().mockReturnValue(chain)
const rpc = vi.fn().mockResolvedValue({ data: [rpcRow], error: null })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc, from } as any)
return { from, chain }
}
const unboundRow = {
user_id: 'user-123',
company_id: null,
api_key_id: 'ak_1',
api_key_name: 'MCP-klient (OAuth)',
scopes: ['transactions:read'],
rate_limited: false,
mode: 'live',
}
it('binds the key to the user\'s company once one exists and heals the row', async () => {
const { from, chain } = setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBe('company-789')
expect(from).toHaveBeenCalledWith('api_keys')
expect(chain.update).toHaveBeenCalledWith({ company_id: 'company-789' })
expect(chain.eq).toHaveBeenCalledWith('id', 'ak_1')
// Only an unbound row is ever rewritten: a concurrent bind must not be clobbered.
expect(chain.is).toHaveBeenCalledWith('company_id', null)
})
it('returns companyId null while the user still has no company', async () => {
const { from } = setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockResolvedValue(null)
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBeNull()
expect(from).not.toHaveBeenCalled()
})
it('treats a failed company resolution as still unbound rather than failing the key', async () => {
setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockRejectedValue(new Error('db blip'))
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBeNull()
expect('userId' in result && result.userId).toBe('user-123')
})
it('skips the heal when the RPC predates api_key_id in its return shape', async () => {
const { from } = setupUnboundKeyClient({ ...unboundRow, api_key_id: undefined })
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBe('company-789')
expect(from).not.toHaveBeenCalled()
})
})
})