Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma strängvärde byggs med konkatenering i stället för att matcha mönstret.
139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
|
|
import { getPool } from './setup'
|
|
|
|
/**
|
|
* api_keys.unattended_commit_limit (migration 20260831111519).
|
|
*
|
|
* The column is the storage half of the approval-authority envelope; the
|
|
* enforcement half is TypeScript (lib/pending-operations/unattended-limit.ts),
|
|
* deliberately, because a RAISE inside commit_journal_entry is swallowed into
|
|
* a retryable 500 and burns the staged operation.
|
|
*
|
|
* What must hold in the database, and is what these tests pin:
|
|
* 1. the column exists, is nullable, and defaults to NULL (unlimited), so
|
|
* every key that existed before this migration keeps its behaviour;
|
|
* 2. the CHECK rejects 0 and negatives, so "limit = 0" can never be stored
|
|
* and silently read back as falsy-therefore-unlimited;
|
|
* 3. validate_and_increment_api_key returns it, with exactly ONE signature
|
|
* (adding a parameter or return column to a Postgres function creates an
|
|
* overload rather than replacing it, and PostgREST then 300s on the
|
|
* ambiguity: see migration 20260421140000);
|
|
* 4. writing it produces an audit_log row, since changing how much an agent
|
|
* may post without a human is a change to who approves the company's
|
|
* bookkeeping (BFL 5 kap. 11 paragraf).
|
|
*/
|
|
describe('api_keys.unattended_commit_limit (pg)', () => {
|
|
async function seedKey(limit: number | null = null) {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
|
const apiKeyId = randomUUID()
|
|
const keyHash = randomUUID().replaceAll('-', '')
|
|
await getPool().query(
|
|
`INSERT INTO public.api_keys
|
|
(id, user_id, company_id, key_hash, key_prefix, name, scopes, unattended_commit_limit)
|
|
VALUES ($1, $2, $3, $4, 'gnubok_sk_test', 'Envelope test key', $5, $6)`,
|
|
[apiKeyId, userId, companyId, keyHash, ['reports:read'], limit],
|
|
)
|
|
return { userId, companyId, apiKeyId, keyHash }
|
|
}
|
|
|
|
it('defaults to NULL so pre-existing keys stay unlimited', async () => {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
|
// Deliberately omits the column instead of storing an explicit NULL: this
|
|
// test exists to pin the DATABASE DEFAULT, and passing NULL in would keep
|
|
// it green even if the default changed to a positive ceiling, which is the
|
|
// one change that would silently start blocking every existing key.
|
|
const { rows } = await getPool().query<{ unattended_commit_limit: string | null }>(
|
|
`INSERT INTO public.api_keys
|
|
(user_id, company_id, key_hash, key_prefix, name, scopes)
|
|
VALUES ($1, $2, $3, 'gnubok_sk_test', 'Default test key', $4)
|
|
RETURNING unattended_commit_limit`,
|
|
[userId, companyId, randomUUID().replaceAll('-', ''), ['reports:read']],
|
|
)
|
|
expect(rows[0]!.unattended_commit_limit).toBeNull()
|
|
})
|
|
|
|
it('rejects a zero or negative ceiling with the CHECK constraint', async () => {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
|
|
|
for (const bad of [0, -1]) {
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.api_keys
|
|
(user_id, company_id, key_hash, key_prefix, name, scopes, unattended_commit_limit)
|
|
VALUES ($1, $2, $3, 'gnubok_sk_test', 'Bad ceiling', $4, $5)`,
|
|
[userId, companyId, randomUUID().replaceAll('-', ''), ['reports:read'], bad],
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: '23514',
|
|
constraint: 'api_keys_unattended_commit_limit_positive',
|
|
})
|
|
}
|
|
})
|
|
|
|
it('validate_and_increment_api_key returns the ceiling, and has exactly one signature', async () => {
|
|
const { keyHash } = await seedKey(2500)
|
|
|
|
const overloads = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'public' AND p.proname = 'validate_and_increment_api_key'`,
|
|
)
|
|
expect(overloads.rows[0]!.n).toBe(1)
|
|
|
|
const { rows } = await getPool().query<{
|
|
unattended_commit_limit: string | null
|
|
rate_limited: boolean
|
|
}>(`SELECT * FROM public.validate_and_increment_api_key($1)`, [keyHash])
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.rate_limited).toBe(false)
|
|
// numeric comes back as a string from node-postgres; compare numerically.
|
|
expect(Number(rows[0]!.unattended_commit_limit)).toBe(2500)
|
|
})
|
|
|
|
it('returns NULL for a key with no ceiling, not 0', async () => {
|
|
const { keyHash } = await seedKey(null)
|
|
const { rows } = await getPool().query<{ unattended_commit_limit: string | null }>(
|
|
`SELECT * FROM public.validate_and_increment_api_key($1)`,
|
|
[keyHash],
|
|
)
|
|
// The whole guard is written NULL-first. A 0 here would read as a real
|
|
// ceiling on the way in and block every commit the key attempts.
|
|
expect(rows[0]!.unattended_commit_limit).toBeNull()
|
|
})
|
|
|
|
it('records a ceiling change in audit_log', async () => {
|
|
const { apiKeyId } = await seedKey(null)
|
|
|
|
await getPool().query(
|
|
`UPDATE public.api_keys SET unattended_commit_limit = 10000 WHERE id = $1`,
|
|
[apiKeyId],
|
|
)
|
|
|
|
const { rows } = await getPool().query<{
|
|
action: string
|
|
old_limit: string | null
|
|
new_limit: string | null
|
|
}>(
|
|
`SELECT action,
|
|
old_state ->> 'unattended_commit_limit' AS old_limit,
|
|
new_state ->> 'unattended_commit_limit' AS new_limit
|
|
FROM public.audit_log
|
|
WHERE table_name = 'api_keys' AND record_id = $1 AND action = 'UPDATE'
|
|
ORDER BY created_at, id`,
|
|
[apiKeyId],
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.old_limit).toBeNull()
|
|
expect(Number(rows[0]!.new_limit)).toBe(10000)
|
|
})
|
|
})
|