679b154ad2
* feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) Expose the complete Skatteverket extension as five MCP tools so VAT (momsdeklaration) and employer (AGI/arbetsgivardeklaration) filing can be driven from Claude. Commit = "send for BankID signing" (returns a signing link), never "file" — the user's signature in the browser is the irreversible act, kept outside the tooling. Tools (extensions/general/mcp-server/server.ts): - gnubok_vat_declaration_validate (compliance:read) — live POST /kontrollera - gnubok_vat_declaration_submit (skatteverket:write) — stages submit_vat_declaration - gnubok_vat_declaration_status (compliance:read) — GET /inlamnat + /beslutat - gnubok_agi_submit (skatteverket:write) — stages submit_agi - gnubok_agi_status (compliance:read) — local state + live kvittenser Architecture: - Core (lib/pending-operations/commit.ts) cannot import @/extensions (CI guard), so the two submit ops dispatch into the extension via the new Extension.services channel (first use): registry-resolved commitSubmitVatDeclaration / commitSubmitAgi run the SKV chain and return a shared SkvSubmitResult (lib/pending-operations/skatteverket-commit.ts). - Recoverable failures (extension disabled, no connection, rate-limited, still processing) release the op back to 'pending' via SkatteverketRecoverableError — same contract as AccountsNotInChartError — so the user reconnects and re-approves the SAME op. SKV business rejections reject the op. - No-drift: parseDeclarationRequest / loadAGIXml extracted to lib/declaration-prep.ts (buildMomsuppgift / buildAgiUnderlag / resolveRedovisare) so route, preview, and commit file identical figures. writeSkatteverketAudit hoisted to lib/audit.ts; read tools + executors write BFL audit rows too. - New scope skatteverket:write (opt-in, in STAGING_SCOPES so SoD ack fires), 4 structured error codes, sv/en strings, ApiKeysPanel row. - Migration 20260620120000 adds submit_vat_declaration / submit_agi to the pending_operations.operation_type CHECK (must apply to prod post-merge). Tests: 42 new across executors, MCP tools, declaration-prep, error-map, and the VAT commit chain. Full suite green (5287), build clean, lint-ratchet at baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: add PR-Agent AI review (SHA-pinned, dedicated Bedrock key) Greptile went silent after #682 (app/account-side, not repo config). Add the open-source PR-Agent GitHub Action as a replacement, hardened for supply chain: - Pinned to the v0.36.0 commit SHA (ffe1f89), not the movable tag — the repo was recently transferred to a new, unverified org (The-PR-Agent), though it's the genuine original pr-agent (repo id 662766482, 11.5k stars). - Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) via PR_AGENT_AWS_* secrets — never the app's general AWS credentials. - Only /review runs automatically; /describe and /improve are disabled so PR descriptions are never overwritten. Requires three new secrets before it functions: PR_AGENT_AWS_ACCESS_KEY_ID, PR_AGENT_AWS_SECRET_ACCESS_KEY, PR_AGENT_AWS_REGION (EU region). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(pr-agent): handle push events + restrict push to /review PR-Agent skips synchronize (push) events by default, so the bot ran green but posted nothing. Enable handle_push_trigger and scope push_commands to /review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(pr-agent): fix pr_actions (event list, not commands) + add synchronize pr_actions is the list of PR event actions to handle, not slash-commands. Setting it to ["/review"] removed every real event from the allowlist, so the bot skipped everything. Restore the default events + synchronize; command selection stays on the auto_review/describe/improve booleans (review-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(pr-agent): raise max_model_tokens to 64k for fuller diff coverage Default ~32k input window truncated large PRs. Sonnet 4.6 has 200k context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skatteverket): scope AGI status flips by salary_run_id Bot review (swedish-compliance) caught that commitSubmitAgi flipped agi_declarations status by (company_id, period) only. A correction run sharing the period would have its still-valid declaration co-flipped to rejected/ pending_signature. Scope both updates by salary_run_id (in scope from params) — more precise than the period-only route handler, which has no run id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(pg): fix gen_random_bytes assertion for modern pgcrypto OpenSSL-backed pgcrypto (CI Postgres image) rejects gen_random_bytes(0) with 'Length not in range' rather than returning empty bytea, so the pre-existing 'returns empty bytea' assertion fails on every pg-real run (repo-wide, not specific to this PR). Assert the real contract — exactly n bytes for a positive n — instead of the version-dependent 0-byte edge case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
7.0 KiB
TypeScript
198 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { getPool } from '@/tests/pg/setup'
|
|
|
|
/**
|
|
* Covers 20230101000000_extension_function_wrappers:
|
|
*
|
|
* - Wrapper existence: public.uuid_generate_v4() and
|
|
* public.gen_random_bytes() are present and resolve correctly.
|
|
*
|
|
* - Delegation: public.uuid_generate_v4() → extensions.uuid_generate_v4()
|
|
* and public.gen_random_bytes() → extensions.gen_random_bytes().
|
|
*
|
|
* - Correctness: uuid_generate_v4() produces valid non-null UUIDs with
|
|
* distinct values across calls. gen_random_bytes(n) returns a bytea of
|
|
* exactly n bytes (n >= 1; OpenSSL-backed pgcrypto rejects size 0).
|
|
*
|
|
* - Function properties: VOLATILE, PARALLEL SAFE, SET search_path = '',
|
|
* LANGUAGE sql — consistent with the rest of the codebase.
|
|
*/
|
|
|
|
describe('extension function wrappers', () => {
|
|
it('uuid_generate_v4() exists in public schema and returns a valid UUID', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{ uuid: string }>(
|
|
`SELECT public.uuid_generate_v4() AS uuid`,
|
|
)
|
|
const uuid = r.rows[0]!.uuid
|
|
expect(uuid).toBeTruthy()
|
|
// UUID v4 format: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx
|
|
expect(uuid).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
)
|
|
})
|
|
|
|
it('uuid_generate_v4() returns distinct values across calls (no caching)', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{ uuid: string }>(
|
|
`SELECT public.uuid_generate_v4() AS uuid
|
|
UNION ALL
|
|
SELECT public.uuid_generate_v4()`,
|
|
)
|
|
expect(r.rows).toHaveLength(2)
|
|
expect(r.rows[0]!.uuid).not.toBe(r.rows[1]!.uuid)
|
|
})
|
|
|
|
it('uuid_generate_v4() can be used as a column DEFAULT expression', async () => {
|
|
const pool = getPool()
|
|
// Create a temp table using the wrapper as default, insert a row, check
|
|
// the UUID was generated and is valid.
|
|
await pool.query(`
|
|
CREATE TEMP TABLE _test_uuid_default (
|
|
id uuid PRIMARY KEY DEFAULT public.uuid_generate_v4(),
|
|
label text
|
|
)
|
|
`)
|
|
await pool.query(`INSERT INTO _test_uuid_default (label) VALUES ('test')`)
|
|
const r = await pool.query<{ id: string }>(`SELECT id FROM _test_uuid_default`)
|
|
expect(r.rows[0]!.id).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
)
|
|
await pool.query(`DROP TABLE _test_uuid_default`)
|
|
})
|
|
|
|
it('gen_random_bytes() exists in public schema', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{ ok: boolean }>(
|
|
`SELECT pg_get_functiondef('public.gen_random_bytes(integer)'::regprocedure) IS NOT NULL AS ok`,
|
|
)
|
|
expect(r.rows[0]!.ok).toBe(true)
|
|
})
|
|
|
|
it('gen_random_bytes(n) returns bytea of exactly n bytes', async () => {
|
|
const pool = getPool()
|
|
for (const n of [1, 16, 32, 64]) {
|
|
const r = await pool.query<{ len: number }>(
|
|
`SELECT octet_length(public.gen_random_bytes($1)) AS len`,
|
|
[n],
|
|
)
|
|
expect(r.rows[0]!.len).toBe(n)
|
|
}
|
|
})
|
|
|
|
it('gen_random_bytes(n) returns exactly n bytes', async () => {
|
|
// Modern (OpenSSL-backed) pgcrypto rejects size 0 with "Length not in
|
|
// range", so we assert the real contract on a positive length rather than
|
|
// the version-dependent 0-byte edge case.
|
|
const pool = getPool()
|
|
const r = await pool.query<{ len: number }>(
|
|
`SELECT octet_length(public.gen_random_bytes(16)) AS len`,
|
|
)
|
|
expect(r.rows[0]!.len).toBe(16)
|
|
})
|
|
|
|
it('gen_random_bytes returns distinct values across calls', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{ a: Buffer; b: Buffer }>(
|
|
`SELECT public.gen_random_bytes(16) AS a, public.gen_random_bytes(16) AS b`,
|
|
)
|
|
expect(r.rows[0]!.a).not.toEqual(r.rows[0]!.b)
|
|
})
|
|
|
|
it('uuid_generate_v4() is VOLATILE and PARALLEL SAFE', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{
|
|
provolatile: string
|
|
proparallel: string
|
|
}>(
|
|
`SELECT provolatile, proparallel
|
|
FROM pg_proc
|
|
WHERE oid = 'public.uuid_generate_v4()'::regprocedure`,
|
|
)
|
|
expect(r.rows[0]!.provolatile).toBe('v')
|
|
expect(r.rows[0]!.proparallel).toBe('s')
|
|
})
|
|
|
|
it('gen_random_bytes(integer) is VOLATILE and PARALLEL SAFE', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{
|
|
provolatile: string
|
|
proparallel: string
|
|
}>(
|
|
`SELECT provolatile, proparallel
|
|
FROM pg_proc
|
|
WHERE oid = 'public.gen_random_bytes(integer)'::regprocedure`,
|
|
)
|
|
expect(r.rows[0]!.provolatile).toBe('v')
|
|
expect(r.rows[0]!.proparallel).toBe('s')
|
|
})
|
|
|
|
it('both wrappers delegate to extensions schema functions', async () => {
|
|
const pool = getPool()
|
|
// Verify the extensions schema exists and has the source functions.
|
|
const extFns = await pool.query<{ name: string; schema: string }>(
|
|
`SELECT p.proname AS name, n.nspname AS schema
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'extensions'
|
|
AND p.proname IN ('uuid_generate_v4', 'gen_random_bytes')`,
|
|
)
|
|
expect(extFns.rows).toHaveLength(2)
|
|
|
|
// Verify the public wrappers exist.
|
|
const pubFns = await pool.query<{ name: string; schema: string }>(
|
|
`SELECT p.proname AS name, n.nspname AS schema
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'public'
|
|
AND p.proname IN ('uuid_generate_v4', 'gen_random_bytes')`,
|
|
)
|
|
expect(pubFns.rows).toHaveLength(2)
|
|
})
|
|
|
|
it('wrappers are defined in SQL (not plpgsql or C)', async () => {
|
|
const pool = getPool()
|
|
const r = await pool.query<{ proname: string; prolang: string }>(
|
|
`SELECT proname, l.lanname AS prolang
|
|
FROM pg_proc p
|
|
JOIN pg_language l ON l.oid = p.prolang
|
|
WHERE p.proname IN ('uuid_generate_v4', 'gen_random_bytes')
|
|
AND p.pronamespace = 'public'::regnamespace`,
|
|
)
|
|
for (const row of r.rows) {
|
|
expect(row.prolang).toBe('sql')
|
|
}
|
|
})
|
|
|
|
it('wrappers are idempotent — CREATE OR REPLACE succeeds on re-run', async () => {
|
|
const pool = getPool()
|
|
// Simulate re-applying the migration: re-running the same CREATE OR REPLACE
|
|
// should succeed without errors and the functions should still work.
|
|
await pool.query(`
|
|
CREATE OR REPLACE FUNCTION public.uuid_generate_v4()
|
|
RETURNS uuid
|
|
LANGUAGE sql
|
|
VOLATILE
|
|
PARALLEL SAFE
|
|
SET search_path = ''
|
|
AS $$ SELECT extensions.uuid_generate_v4(); $$
|
|
`)
|
|
await pool.query(`
|
|
CREATE OR REPLACE FUNCTION public.gen_random_bytes(size integer)
|
|
RETURNS bytea
|
|
LANGUAGE sql
|
|
VOLATILE
|
|
PARALLEL SAFE
|
|
SET search_path = ''
|
|
AS $$ SELECT extensions.gen_random_bytes(size); $$
|
|
`)
|
|
// Functions must still work after re-creation.
|
|
const r = await pool.query<{ uuid: string; bytes_len: number }>(
|
|
`SELECT public.uuid_generate_v4() AS uuid,
|
|
octet_length(public.gen_random_bytes(32)) AS bytes_len`,
|
|
)
|
|
expect(r.rows[0]!.uuid).toBeTruthy()
|
|
expect(r.rows[0]!.bytes_len).toBe(32)
|
|
})
|
|
})
|