217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
const migration = readFileSync(
|
|
new URL(
|
|
"../supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql",
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
const dbMigrationsCopy = fileURLToPath(
|
|
new URL("../db/migrations/20260812010000_agentic_rectification_v9_runtime.sql", import.meta.url),
|
|
);
|
|
|
|
test("v9 runtime migration sorts after the newest business migration and stays unique", () => {
|
|
assert.ok(
|
|
"20260812010000_agentic_rectification_v9_runtime.sql" >
|
|
"20260811010000_consultation_status_service_role_read.sql",
|
|
);
|
|
assert.match(migration, /^begin;[\s\S]*^commit;$/m);
|
|
});
|
|
|
|
test("v9 runtime migration must never be duplicated into the identity foundation", () => {
|
|
assert.equal(
|
|
existsSync(dbMigrationsCopy),
|
|
false,
|
|
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
|
);
|
|
});
|
|
|
|
test("v9 runtime migration creates the five durable tables with required columns", () => {
|
|
assert.match(migration, /create table if not exists public\.agentic_rectification_cases \(/);
|
|
assert.match(migration, /user_id uuid not null references auth\.users\(id\) on delete cascade/);
|
|
assert.match(migration, /session_id uuid not null unique references public\.chat_sessions\(id\) on delete cascade/);
|
|
assert.match(migration, /baseline_profile_fingerprint text not null/);
|
|
assert.match(migration, /baseline_birth_snapshot jsonb not null/);
|
|
assert.match(migration, /skill_version text not null/);
|
|
assert.match(migration, /accepted_time time without time zone/);
|
|
assert.match(migration, /confirmed_time time without time zone/);
|
|
assert.match(migration, /completed_at timestamptz/);
|
|
assert.match(migration, /closed_reason text/);
|
|
|
|
assert.match(migration, /create table if not exists public\.agentic_rectification_evidence \(/);
|
|
assert.match(migration, /source_turn_id uuid not null references public\.agentic_rectification_turns\(id\)/);
|
|
assert.match(migration, /user_quote text not null/);
|
|
assert.match(migration, /event_kind text not null/);
|
|
assert.match(migration, /date_precision text not null check \(date_precision in \('year', 'month', 'day', 'range', 'unknown'\)\)/);
|
|
assert.match(migration, /supersedes_evidence_id uuid references public\.agentic_rectification_evidence\(id\)/);
|
|
|
|
assert.match(migration, /create table if not exists public\.agentic_rectification_turns \(/);
|
|
assert.match(migration, /status text not null check \(status in \('pending', 'completed', 'failed', 'retryable'\)\)/);
|
|
assert.match(migration, /model_name text not null/);
|
|
const turnsTable = migration.slice(
|
|
migration.indexOf("create table if not exists public.agentic_rectification_turns"),
|
|
migration.indexOf("-- 3. Evidence"),
|
|
);
|
|
assert.doesNotMatch(turnsTable, /reasoning/);
|
|
|
|
assert.match(migration, /create table if not exists public\.agentic_rectification_tool_receipts \(/);
|
|
assert.match(migration, /input_fingerprint text/);
|
|
assert.match(migration, /result_fingerprint text/);
|
|
assert.match(migration, /safe_error_code text/);
|
|
assert.doesNotMatch(migration, /create table if not exists public\.agentic_rectification_tool_receipts \([\s\S]*payload/);
|
|
|
|
assert.match(migration, /create table if not exists public\.agentic_rectification_open_ledger \(/);
|
|
assert.match(migration, /primary key \(user_id, request_id\)/);
|
|
});
|
|
|
|
test("v9 runtime migration enforces one resumable case per user at the database level", () => {
|
|
assert.match(
|
|
migration,
|
|
/create unique index if not exists agentic_rectification_cases_one_resumable_per_user[\s\S]*where status in \([\s\S]*'draft'[\s\S]*'collecting_evidence'[\s\S]*'candidate_ready'[\s\S]*'candidate_accepted'[\s\S]*'needs_rebaseline'[\s\S]*'paused'[\s\S]*\)/,
|
|
);
|
|
});
|
|
|
|
test("v9 runtime migration keeps Case/Session bidirectional consistency", () => {
|
|
assert.match(migration, /alter table public\.chat_sessions\s+add column if not exists agentic_rectification_case_id uuid/);
|
|
assert.match(migration, /create unique index if not exists chat_sessions_agentic_rectification_case_unique/);
|
|
assert.match(migration, /agentic_rectification_cases_sync_session/);
|
|
assert.match(migration, /agentic_rectification_case_session_mismatch/);
|
|
assert.match(migration, /agentic_rectification_case_owner_mismatch/);
|
|
});
|
|
|
|
test("v9 runtime migration extends results with case_id and fingerprints", () => {
|
|
assert.match(migration, /add column if not exists case_id uuid/);
|
|
assert.match(migration, /add column if not exists evidence_ledger_fingerprint text/);
|
|
assert.match(migration, /add column if not exists candidate_range_fingerprint text/);
|
|
assert.match(migration, /add column if not exists skill_version text/);
|
|
});
|
|
|
|
test("v9 runtime migration grants only service_role on the new tables", () => {
|
|
for (const table of [
|
|
"agentic_rectification_cases",
|
|
"agentic_rectification_turns",
|
|
"agentic_rectification_evidence",
|
|
"agentic_rectification_tool_receipts",
|
|
"agentic_rectification_open_ledger",
|
|
]) {
|
|
assert.match(migration, new RegExp(`revoke all on table public\\.${table} from public, anon, authenticated, service_role`));
|
|
assert.match(migration, new RegExp(`grant all on table public\\.${table} to service_role`));
|
|
assert.doesNotMatch(migration, new RegExp(`grant select on table public\\.${table} to authenticated`));
|
|
}
|
|
});
|
|
|
|
test("v9 RPCs are security definer and service-role only", () => {
|
|
for (const functionName of [
|
|
"open_agentic_rectification_case",
|
|
"get_agentic_rectification_entry_summary",
|
|
"get_agentic_rectification_case",
|
|
"close_agentic_rectification_case",
|
|
"upgrade_agentic_rectification_skill",
|
|
"append_agentic_rectification_turn",
|
|
"insert_agentic_rectification_tool_receipt",
|
|
"propose_agentic_rectification_evidence",
|
|
"confirm_agentic_rectification_evidence",
|
|
"revise_agentic_rectification_evidence",
|
|
]) {
|
|
assert.match(migration, new RegExp(`create or replace function public\\.${functionName}\\(`));
|
|
assert.match(migration, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`));
|
|
}
|
|
assert.doesNotMatch(
|
|
migration,
|
|
/grant execute on function public\.open_agentic_rectification_case\([\s\S]*?to authenticated/,
|
|
);
|
|
});
|
|
|
|
test("open RPC serializes same-user requests and forbids silent supersede", () => {
|
|
assert.match(migration, /pg_catalog\.pg_advisory_xact_lock\(/);
|
|
assert.match(migration, /hashtext\('agentic_rectification_open:' \|\| p_user_id::text\)/);
|
|
assert.match(migration, /agentic_rectification_active_case_conflict/);
|
|
assert.doesNotMatch(migration, /supersede_active/);
|
|
assert.doesNotMatch(migration, /p_supersede/);
|
|
});
|
|
|
|
test("open RPC creates the case and session atomically and derives shouldStartOpening", () => {
|
|
const open = migration.slice(
|
|
migration.indexOf("create or replace function public.open_agentic_rectification_case"),
|
|
migration.indexOf("-- 12. Entry summary"),
|
|
);
|
|
assert.match(open, /insert into public\.chat_sessions/);
|
|
assert.match(open, /insert into public\.agentic_rectification_cases/);
|
|
assert.match(open, /insert into public\.agentic_rectification_open_ledger/);
|
|
assert.match(open, /exception when unique_violation/);
|
|
assert.match(open, /'should_start_opening', true/);
|
|
// Snapshot/range validation lives inside the create block, before the
|
|
// inserts -- a session/view-only open never needs a complete profile.
|
|
const createBlock = open.slice(
|
|
open.indexOf("Create a new case + a new session atomically"),
|
|
open.lastIndexOf("return jsonb_build_object("),
|
|
);
|
|
assert.match(createBlock, /agentic_rectification_profile_incomplete/);
|
|
assert.match(createBlock, /agentic_rectification_invalid_range/);
|
|
});
|
|
|
|
test("open RPC never creates a case for an incomplete profile", () => {
|
|
assert.match(migration, /agentic_rectification_profile_incomplete/);
|
|
assert.match(migration, /p_baseline_birth_snapshot ->> 'birth_date' is null/);
|
|
});
|
|
|
|
test("terminal cases reject evidence and turn writes", () => {
|
|
const propose = migration.slice(
|
|
migration.indexOf("create or replace function public.propose_agentic_rectification_evidence"),
|
|
migration.indexOf("create or replace function public.confirm_agentic_rectification_evidence"),
|
|
);
|
|
assert.match(propose, /agentic_rectification_case_terminal/);
|
|
const append = migration.slice(
|
|
migration.indexOf("create or replace function public.append_agentic_rectification_turn"),
|
|
migration.indexOf("-- 17. Tool receipt"),
|
|
);
|
|
assert.match(append, /agentic_rectification_case_terminal/);
|
|
});
|
|
|
|
test("evidence proposals require quote grounding in the source turn", () => {
|
|
assert.match(migration, /agentic_rectification_quote_not_grounded/);
|
|
assert.match(migration, /agentic_rectification_normalize_quote\(p_user_quote\)/);
|
|
assert.match(migration, /agentic_rectification_normalize_quote\(v_turn\.user_message\)/);
|
|
});
|
|
|
|
test("evidence revisions are append-only and never generate confirmed evidence from text", () => {
|
|
assert.match(migration, /set status = 'superseded'/);
|
|
assert.match(migration, /supersedes_evidence_id[\s\S]*'pending_confirmation', v_target\.id/);
|
|
const backfill = migration.slice(
|
|
migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"),
|
|
migration.indexOf("-- 20. Backfill verification"),
|
|
);
|
|
assert.doesNotMatch(backfill, /insert into public\.agentic_rectification_evidence/);
|
|
});
|
|
|
|
test("legacy backfill maps every documented status and keeps one resumable per user", () => {
|
|
const backfill = migration.slice(
|
|
migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"),
|
|
migration.indexOf("-- 20. Backfill verification"),
|
|
);
|
|
assert.match(backfill, /when result_kind = 'engine_confirmed' then 'confirmed'/);
|
|
assert.match(backfill, /when result_kind = 'user_accepted' then 'candidate_accepted'/);
|
|
assert.match(backfill, /when has_messages then case when has_results then 'candidate_ready' else 'collecting_evidence' end/);
|
|
assert.match(backfill, /then 'superseded'/);
|
|
assert.match(backfill, /then 'draft'/);
|
|
assert.match(backfill, /agentic_rectification_legacy_fingerprint/);
|
|
assert.match(backfill, /update public\.agentic_rectification_results\s+set case_id = v_case_id/);
|
|
assert.match(backfill, /not exists \(\s*select 1 from public\.agentic_rectification_cases c where c\.session_id = s\.id\s*\)/);
|
|
});
|
|
|
|
test("backfill apply runs inside the migration and verification is re-runnable", () => {
|
|
assert.match(migration, /select public\.backfill_agentic_rectification_legacy_cases\(\) into v_result/);
|
|
assert.match(migration, /create or replace function public\.verify_agentic_rectification_backfill\(\)/);
|
|
assert.match(migration, /resumable_conflicts/);
|
|
assert.match(migration, /orphan_cases/);
|
|
});
|
|
|
|
test("v9 fingerprint uses the already-installed pgcrypto digest", () => {
|
|
assert.match(migration, /public\.digest\(/);
|
|
assert.match(migration, /'sha256'/);
|
|
});
|