Files
Jyotisha/frontend/tests/personal-report-migration.test.ts

130 lines
6.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { PERSONAL_REPORT_FAILURE_CODES } from "../src/lib/personal-report-service-core.ts";
const localMigration = readFileSync(
new URL("../db/migrations/20260806000000_personal_reports.sql", import.meta.url),
"utf8",
);
const supabaseMigration = readFileSync(
new URL("../supabase/migrations/20260806010000_personal_reports.sql", import.meta.url),
"utf8",
);
const localMigrations = [
"20260714000000_local_auth_compatibility.sql",
"20260720000100_backend_foundation.sql",
"20260721000100_self_hosted_identity.sql",
"20260721000200_identity_business_bridge.sql",
"20260727000000_admin_viewer_identity.sql",
"20260806000000_personal_reports.sql",
];
const supabaseMigrations = [
"20260805030000_reconcile_rectification_v4_conversational_turns.sql",
"20260806010000_personal_reports.sql",
];
test("migration filenames use unique, correctly ordered 14-digit versions", () => {
const pattern = /^\d{14}_[a-z0-9_]+\.sql$/;
assert.match("20260806000000_personal_reports.sql", pattern);
assert.match("20260806010000_personal_reports.sql", pattern);
assert.ok(localMigrations[5] > localMigrations[4], "local migration must sort after existing ones");
assert.ok(supabaseMigrations[1] > supabaseMigrations[0], "supabase migration must sort after existing ones");
const versions = [...localMigrations, ...supabaseMigrations].map((name) => name.split("_")[0]);
assert.equal(new Set(versions).size, versions.length, "all migration versions must be unique");
});
test("both migrations define the same table shape with request_fingerprint after request_id", () => {
const parseColumns = (sql: string) => {
const body = sql.split("create table if not exists public.personal_reports")[1].split(");")[0];
const columns: string[] = [];
let depth = 0;
for (const raw of body.split("\n")) {
const line = raw.trim();
if (!line) continue;
const before = depth;
for (const ch of line) {
if (ch === "(") depth += 1;
if (ch === ")") depth -= 1;
}
const first = line.split(/\s+/)[0];
if (before === 1 && /^[a-z_][a-z0-9_]*$/.test(first) && first !== "check" && first !== "unique") {
columns.push(first);
}
}
return columns;
};
const expected = [
"id", "user_id", "session_id", "chart_profile_id", "request_id",
"request_fingerprint", "report_type", "status", "schema_version",
"presentation_mode", "requested_themes", "report_document",
"calculation_hash", "evidence_hash", "skill_source_commit",
"skill_snapshot_sha256", "failure_code", "created_at", "updated_at",
"completed_at",
];
assert.deepEqual(parseColumns(localMigration), expected);
assert.deepEqual(parseColumns(supabaseMigration), expected);
});
test("request_fingerprint is not null and restricted to sha256 hex in both migrations", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /request_fingerprint text not null check \(request_fingerprint ~ '\^\[0-9a-f\]\{64\}\$'\)/);
}
});
test("status, report_type, presentation_mode and schema_version checks are identical", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /status text not null check \(status in \('generating', 'ready', 'failed'\)\)/);
assert.match(sql, /report_type text not null check \(report_type in \('personal_full', 'personal_thematic'\)\)/);
assert.match(sql, /presentation_mode text not null check \(presentation_mode in \('default', 'research'\)\)/);
assert.match(sql, /schema_version text not null check \(schema_version = 'report_document\.v1'\)/);
}
});
test("ready requires document, completion time and both hashes; failed requires failure_code", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /check \(\(status = 'ready'\) = \(report_document is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(completed_at is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(calculation_hash is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(evidence_hash is not null\)\)/);
assert.match(sql, /check \(\(status = 'failed'\) = \(failure_code is not null\)\)/);
}
});
test("failure_code enum in SQL matches the service constant exactly", () => {
for (const sql of [localMigration, supabaseMigration]) {
const block = sql.match(/failure_code text check \(failure_code in \(([\s\S]+?)\)\)/)?.[1] ?? "";
const sqlCodes = [...block.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]);
assert.deepEqual(sqlCodes, [...PERSONAL_REPORT_FAILURE_CODES]);
}
});
test("both migrations keep the (user_id, request_id) lock and one-generating-per-user index", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /unique \(user_id, request_id\)/);
assert.match(sql, /create unique index if not exists personal_reports_one_generating_per_user[\s\S]*where status = 'generating'/);
assert.match(sql, /references auth\.users\(id\) on delete cascade/);
}
});
test("RLS policies plus explicit owner grants: select/delete only, never insert/update", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /alter table public\.personal_reports enable row level security/);
assert.match(sql, /create policy personal_reports_select_own[\s\S]*for select[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/);
assert.match(sql, /create policy personal_reports_delete_own[\s\S]*for delete[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/);
// Policies alone do not grant privileges: explicit owner grants are required.
assert.match(sql, /grant select, delete on table public\.personal_reports to authenticated/);
assert.doesNotMatch(sql, /grant (insert|update) on table public\.personal_reports to authenticated/);
assert.match(sql, /grant select, insert, update, delete on table public\.personal_reports to service_role/);
}
});
test("least privilege: anon/public revoked and no direct admin_runtime body access", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /revoke all on table public\.personal_reports from public, anon, authenticated/);
}
assert.doesNotMatch(localMigration, /to admin_runtime/);
assert.doesNotMatch(supabaseMigration, /to admin_runtime/);
});