Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-audit-cascade-guard.mjs
T

144 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
export const schemaPath = path.join(repoRoot, "prisma/schema/accounts.prisma");
export const migrationPath = path.join(
repoRoot,
"prisma/migrations/20260508140000_impersonation_audit/migration.sql",
);
export function extractModel(schemaText, modelName) {
const modelMatch = schemaText.match(new RegExp(`model\\s+${modelName}\\s+\\{([\\s\\S]*?)\\n\\}`));
return modelMatch?.[1] ?? null;
}
function assertMatch(failures, id, text, pattern, message) {
if (!pattern.test(text)) {
failures.push({ id, message });
}
}
function normalizeSql(sqlText) {
return sqlText.replace(/\s+/g, " ").trim();
}
export function verifyAuditCascadeGuard({ schemaText, migrationSql }) {
const failures = [];
const model = extractModel(schemaText, "ImpersonationAudit");
if (!model) {
failures.push({ id: "schema.model.missing", message: "Prisma schema is missing model ImpersonationAudit." });
} else {
assertMatch(
failures,
"schema.actorUserId.nullable",
model,
/^\s*actorUserId\s+String\?/m,
"ImpersonationAudit.actorUserId must be nullable (String?).",
);
assertMatch(
failures,
"schema.targetUserId.nullable",
model,
/^\s*targetUserId\s+String\?/m,
"ImpersonationAudit.targetUserId must be nullable (String?).",
);
assertMatch(
failures,
"schema.actor.optional-set-null",
model,
/^\s*actor\s+User\?\s+@relation\([^\n]*fields:\s*\[actorUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.actor relation must be optional and use onDelete: SetNull.",
);
assertMatch(
failures,
"schema.target.optional-set-null",
model,
/^\s*target\s+User\?\s+@relation\([^\n]*fields:\s*\[targetUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.target relation must be optional and use onDelete: SetNull.",
);
assertMatch(
failures,
"schema.tenant.optional-set-null",
model,
/^\s*tenant\s+Tenant\?\s+@relation\([^\n]*fields:\s*\[tenantId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.tenant relation must be optional and use onDelete: SetNull.",
);
const cascadeRelations = model
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /@relation\(/.test(line) && /onDelete:\s*Cascade/.test(line));
for (const relation of cascadeRelations) {
failures.push({
id: "schema.relation.no-cascade",
message: `ImpersonationAudit relation must not use onDelete: Cascade: ${relation}`,
});
}
}
const normalizedSql = normalizeSql(migrationSql);
assertMatch(
failures,
"migration.actorUserId.nullable",
normalizedSql,
/"actorUserId"\s+TEXT\s*(?:,|$)/,
'Migration column "actorUserId" must be nullable TEXT, not TEXT NOT NULL.',
);
assertMatch(
failures,
"migration.targetUserId.nullable",
normalizedSql,
/"targetUserId"\s+TEXT\s*(?:,|$)/,
'Migration column "targetUserId" must be nullable TEXT, not TEXT NOT NULL.',
);
const expectedForeignKeys = [
["migration.actorUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_actorUserId_fkey"\s+FOREIGN KEY\s+\("actorUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i],
["migration.targetUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_targetUserId_fkey"\s+FOREIGN KEY\s+\("targetUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i],
["migration.tenantId.set-null", /CONSTRAINT\s+"ImpersonationAudit_tenantId_fkey"\s+FOREIGN KEY\s+\("tenantId"\)\s+REFERENCES\s+"Tenant"\("id"\)\s+ON DELETE SET NULL/i],
];
for (const [id, pattern] of expectedForeignKeys) {
assertMatch(failures, id, normalizedSql, pattern, `${id} foreign key must use ON DELETE SET NULL.`);
}
return { ok: failures.length === 0, failures };
}
export function formatAuditCascadeGuardResult(result) {
if (result.ok) {
return "PASS impersonation audit cascade guard: schema and migration retain audit rows with nullable SetNull relations.";
}
return [
"FAIL impersonation audit cascade guard",
...result.failures.map((failure) => `- ${failure.id}: ${failure.message}`),
].join("\n");
}
async function main() {
const [schemaText, migrationSql] = await Promise.all([readFile(schemaPath, "utf8"), readFile(migrationPath, "utf8")]);
const result = verifyAuditCascadeGuard({ schemaText, migrationSql });
const output = formatAuditCascadeGuardResult(result);
if (result.ok) {
console.log(output);
return;
}
console.error(output);
process.exit(1);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}