423 lines
12 KiB
JavaScript
423 lines
12 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import process from "node:process";
|
|
import { createInterface } from "node:readline";
|
|
import pg from "pg";
|
|
|
|
const { Client } = pg;
|
|
const EXPECTED_DATABASE = "booking_test";
|
|
const TARGET_SCHEMA = "condon";
|
|
const BASELINE_FINGERPRINT =
|
|
// Read-only baseline captured from the existing booking/finance/ingestion/
|
|
// reporting schemas before the CONDO import. The CONDO migrations do not
|
|
// target these schemas.
|
|
"4b130b7f0f57eb80bb8b168fb1c11325db4c3b417e7c99cf423f9a48f0322af3";
|
|
const MIGRATION_VERSION = "001_create_condon_schema";
|
|
const MIGRATION_CHECKSUM =
|
|
"7ab84c0c22fca16f75263be7291fc5c1977b46bada0794cf5b069e770d7e07a9";
|
|
const MIGRATION_V2 = "002_legacy_import_and_bookings";
|
|
const MIGRATION_V2_CHECKSUM =
|
|
"cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b";
|
|
const MIGRATION_V3 = "003_delete_usage_record";
|
|
const MIGRATION_V3_CHECKSUM =
|
|
"9110464c03ed18385fbc2da1d4369715fcbf83148d7a0f2550c25710a5f54148";
|
|
|
|
const expectedColumns = {
|
|
bookings: [
|
|
"confirmation_no",
|
|
"created_at"
|
|
],
|
|
entitlement_ledger: [
|
|
"id",
|
|
"entitlement_period_id",
|
|
"usage_record_id",
|
|
"entry_type",
|
|
"delta_nights",
|
|
"balance_before",
|
|
"balance_after",
|
|
"occurred_at"
|
|
],
|
|
entitlement_periods: [
|
|
"id",
|
|
"owner_account_id",
|
|
"period_year",
|
|
"period_start",
|
|
"period_end",
|
|
"annual_grant",
|
|
"carry_forward",
|
|
"current_balance",
|
|
"row_version",
|
|
"created_at",
|
|
"updated_at"
|
|
],
|
|
owner_accounts: [
|
|
"id",
|
|
"account_no",
|
|
"transfer_date",
|
|
"owner_name",
|
|
"room_no",
|
|
"purchased_room_type_code",
|
|
"unit_no",
|
|
"member_no",
|
|
"created_at",
|
|
"updated_at"
|
|
],
|
|
room_types: [
|
|
"code",
|
|
"entitlement_tier",
|
|
"requires_manual_multiplier",
|
|
"created_at",
|
|
"updated_at"
|
|
],
|
|
schema_migrations: [
|
|
"version",
|
|
"checksum",
|
|
"applied_at"
|
|
],
|
|
usage_records: [
|
|
"id",
|
|
"owner_account_id",
|
|
"entitlement_period_id",
|
|
"confirmation_no",
|
|
"check_in",
|
|
"check_out",
|
|
"night_count",
|
|
"used_room_type_code",
|
|
"applied_multiplier",
|
|
"rule_version",
|
|
"use_nights",
|
|
"balance_before",
|
|
"balance_after",
|
|
"remark",
|
|
"idempotency_key",
|
|
"created_at",
|
|
"raw_used_room_type",
|
|
"room_count",
|
|
"source_sheet",
|
|
"source_row",
|
|
"source_sequence",
|
|
"import_batch"
|
|
]
|
|
};
|
|
|
|
const expectedIndexes = [
|
|
"bookings_created_idx",
|
|
"bookings_pkey",
|
|
"entitlement_ledger_period_occurred_idx",
|
|
"entitlement_ledger_pkey",
|
|
"entitlement_ledger_usage_record_key",
|
|
"entitlement_periods_id_owner_key",
|
|
"entitlement_periods_owner_year_key",
|
|
"entitlement_periods_pkey",
|
|
"owner_accounts_account_no_key",
|
|
"owner_accounts_member_no_idx",
|
|
"owner_accounts_pkey",
|
|
"owner_accounts_purchased_room_type_idx",
|
|
"owner_accounts_room_no_key",
|
|
"room_types_pkey",
|
|
"schema_migrations_pkey",
|
|
"usage_records_id_period_key",
|
|
"usage_records_idempotency_key_key",
|
|
"usage_records_owner_created_idx",
|
|
"usage_records_period_idx",
|
|
"usage_records_pkey",
|
|
"usage_records_source_location_key",
|
|
"usage_records_used_type_check_in_idx"
|
|
].sort();
|
|
|
|
const expectedRoomTypes = [
|
|
["AC2", null, true],
|
|
["RM1", 1, false],
|
|
["RM2", 1, false],
|
|
["RM3", 1, false],
|
|
["RM4", 1, false],
|
|
["SU1", 2, false],
|
|
["SU2", 2, false],
|
|
["SU3", 3, false],
|
|
["SU6", 2, false],
|
|
["UG1", 1, false],
|
|
["UG2", 1, false]
|
|
];
|
|
|
|
async function readStandardInput() {
|
|
const lines = createInterface({
|
|
input: process.stdin,
|
|
terminal: false
|
|
});
|
|
for await (const line of lines) {
|
|
lines.close();
|
|
return line.trim();
|
|
}
|
|
throw Object.assign(new Error("Missing connection input"), {
|
|
code: "MISSING_CONNECTION_INPUT"
|
|
});
|
|
}
|
|
|
|
function fingerprint(rows) {
|
|
return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
|
|
}
|
|
|
|
function sameJson(left, right) {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
function sanitizeError(error) {
|
|
return {
|
|
ok: false,
|
|
errorCode: typeof error?.code === "string" ? error.code : "VERIFY_FAILED"
|
|
};
|
|
}
|
|
|
|
let client;
|
|
let transactionStarted = false;
|
|
|
|
try {
|
|
const rawInput = await readStandardInput();
|
|
const input = JSON.parse(rawInput);
|
|
|
|
client = new Client({
|
|
host: input.host,
|
|
port: input.port,
|
|
user: input.user,
|
|
password: input.password,
|
|
database: input.database,
|
|
ssl: input.ssl ? { rejectUnauthorized: false } : undefined,
|
|
application_name: "condon_readonly_verification",
|
|
connectionTimeoutMillis: 10_000,
|
|
query_timeout: 20_000,
|
|
options: "-c default_transaction_read_only=on -c statement_timeout=20000 -c lock_timeout=2000"
|
|
});
|
|
|
|
await client.connect();
|
|
await client.query("BEGIN TRANSACTION READ ONLY");
|
|
transactionStarted = true;
|
|
|
|
const identityResult = await client.query(`
|
|
SELECT
|
|
current_database() AS database_name,
|
|
current_user AS role_name,
|
|
current_setting('transaction_read_only') AS transaction_read_only
|
|
`);
|
|
const identity = identityResult.rows[0];
|
|
|
|
const objectResult = await client.query(`
|
|
SELECT
|
|
n.nspname AS schema_name,
|
|
c.relname AS object_name,
|
|
c.relkind AS object_kind,
|
|
pg_catalog.pg_get_userbyid(c.relowner) AS owner_name
|
|
FROM pg_catalog.pg_class AS c
|
|
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
|
WHERE n.nspname NOT LIKE 'pg_%'
|
|
AND n.nspname <> 'information_schema'
|
|
AND n.nspname <> 'condon'
|
|
ORDER BY n.nspname, c.relkind, c.relname
|
|
`);
|
|
const routineResult = await client.query(`
|
|
SELECT
|
|
n.nspname AS schema_name,
|
|
p.proname AS routine_name,
|
|
pg_catalog.pg_get_userbyid(p.proowner) AS owner_name,
|
|
p.prokind AS routine_kind,
|
|
pg_catalog.pg_get_function_identity_arguments(p.oid) AS identity_arguments
|
|
FROM pg_catalog.pg_proc AS p
|
|
JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace
|
|
WHERE n.nspname NOT LIKE 'pg_%'
|
|
AND n.nspname <> 'information_schema'
|
|
AND n.nspname <> 'condon'
|
|
ORDER BY n.nspname, p.proname, identity_arguments
|
|
`);
|
|
const existingFingerprint = fingerprint({
|
|
objects: objectResult.rows,
|
|
routines: routineResult.rows
|
|
});
|
|
|
|
const columnResult = await client.query(`
|
|
SELECT table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'condon'
|
|
ORDER BY table_name, ordinal_position
|
|
`);
|
|
const actualColumns = columnResult.rows.reduce((tables, row) => {
|
|
tables[row.table_name] ??= [];
|
|
tables[row.table_name].push(row.column_name);
|
|
return tables;
|
|
}, {});
|
|
|
|
const indexResult = await client.query(`
|
|
SELECT indexname AS index_name
|
|
FROM pg_catalog.pg_indexes
|
|
WHERE schemaname = 'condon'
|
|
ORDER BY indexname
|
|
`);
|
|
const actualIndexes = indexResult.rows.map(row => row.index_name);
|
|
|
|
const functionResult = await client.query(`
|
|
SELECT
|
|
p.proname AS function_name,
|
|
p.prosecdef AS security_definer,
|
|
p.provolatile AS volatility,
|
|
p.proconfig @> ARRAY['search_path=pg_catalog']::text[] AS safe_search_path,
|
|
p.proacl IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_catalog.aclexplode(p.proacl) AS acl
|
|
WHERE acl.grantee = 0
|
|
AND acl.privilege_type = 'EXECUTE'
|
|
) AS public_execute_revoked
|
|
FROM pg_catalog.pg_proc AS p
|
|
JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'condon'
|
|
ORDER BY p.proname
|
|
`);
|
|
|
|
const constraintResult = await client.query(`
|
|
SELECT
|
|
count(*)::integer AS constraint_count,
|
|
COALESCE(bool_and(
|
|
c.confrelid = 0
|
|
OR referenced_namespace.nspname = 'condon'
|
|
), true) AS all_foreign_keys_internal
|
|
FROM pg_catalog.pg_constraint AS c
|
|
JOIN pg_catalog.pg_namespace AS own_namespace
|
|
ON own_namespace.oid = c.connamespace
|
|
LEFT JOIN pg_catalog.pg_class AS referenced_class
|
|
ON referenced_class.oid = c.confrelid
|
|
LEFT JOIN pg_catalog.pg_namespace AS referenced_namespace
|
|
ON referenced_namespace.oid = referenced_class.relnamespace
|
|
WHERE own_namespace.nspname = 'condon'
|
|
`);
|
|
|
|
const aclResult = await client.query(`
|
|
SELECT
|
|
n.nspacl IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_catalog.aclexplode(n.nspacl) AS acl
|
|
WHERE acl.grantee = 0
|
|
) AS schema_public_access_revoked,
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_catalog.pg_class AS c
|
|
CROSS JOIN LATERAL pg_catalog.aclexplode(
|
|
COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))
|
|
) AS acl
|
|
WHERE c.relnamespace = n.oid
|
|
AND acl.grantee = 0
|
|
) AS tables_have_no_public_access
|
|
FROM pg_catalog.pg_namespace AS n
|
|
WHERE n.nspname = 'condon'
|
|
`);
|
|
|
|
const roomTypeResult = await client.query(`
|
|
SELECT code, entitlement_tier, requires_manual_multiplier
|
|
FROM condon.room_types
|
|
ORDER BY code
|
|
`);
|
|
const actualRoomTypes = roomTypeResult.rows.map(row => [
|
|
row.code,
|
|
row.entitlement_tier,
|
|
row.requires_manual_multiplier
|
|
]);
|
|
|
|
const countResult = await client.query(`
|
|
SELECT
|
|
(SELECT count(*)::integer FROM condon.owner_accounts) AS owner_account_count,
|
|
(SELECT count(*)::integer FROM condon.entitlement_periods) AS entitlement_period_count,
|
|
(SELECT count(*)::integer FROM condon.usage_records) AS usage_record_count,
|
|
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger_count
|
|
`);
|
|
const businessCounts = countResult.rows[0];
|
|
|
|
const migrationResult = await client.query(`
|
|
SELECT version, checksum
|
|
FROM condon.schema_migrations
|
|
`);
|
|
|
|
const expectedFunctions = {
|
|
calculate_multiplier: { volatility: "s" },
|
|
create_usage_record: { volatility: "v" },
|
|
create_usage_record_v2: { volatility: "v" },
|
|
delete_usage_record: { volatility: "v" },
|
|
ensure_booking_for_usage: { volatility: "v" },
|
|
open_entitlement_period: { volatility: "v" }
|
|
};
|
|
const functionsValid =
|
|
functionResult.rowCount === 6
|
|
&& functionResult.rows.every(row =>
|
|
expectedFunctions[row.function_name]?.volatility === row.volatility
|
|
&& row.security_definer === false
|
|
&& row.safe_search_path === true
|
|
&& row.public_execute_revoked === true
|
|
);
|
|
|
|
const checks = {
|
|
databaseVerified:
|
|
identity.database_name === EXPECTED_DATABASE
|
|
&& identity.role_name === input.user,
|
|
transactionReadOnly: identity.transaction_read_only === "on",
|
|
existingCatalogUnchanged:
|
|
objectResult.rowCount === 128
|
|
&& routineResult.rowCount === 5
|
|
&& existingFingerprint === BASELINE_FINGERPRINT,
|
|
exactTableColumns: sameJson(actualColumns, expectedColumns),
|
|
exactIndexes: sameJson(actualIndexes, expectedIndexes),
|
|
functionsValid,
|
|
constraintsValid:
|
|
constraintResult.rows[0].constraint_count === 49
|
|
&& constraintResult.rows[0].all_foreign_keys_internal === true,
|
|
permissionsRestricted:
|
|
aclResult.rows[0]?.schema_public_access_revoked === true
|
|
&& aclResult.rows[0]?.tables_have_no_public_access === true,
|
|
exactRoomTypes: sameJson(actualRoomTypes, expectedRoomTypes),
|
|
businessDataImported:
|
|
businessCounts.owner_account_count === 388
|
|
&& businessCounts.entitlement_period_count === 389
|
|
&& businessCounts.usage_record_count === 157
|
|
&& businessCounts.ledger_count === 547,
|
|
migrationVerified:
|
|
migrationResult.rowCount === 3
|
|
&& migrationResult.rows.some(row =>
|
|
row.version === MIGRATION_VERSION && row.checksum === MIGRATION_CHECKSUM
|
|
)
|
|
&& migrationResult.rows.some(row =>
|
|
row.version === MIGRATION_V2 && row.checksum === MIGRATION_V2_CHECKSUM
|
|
)
|
|
&& migrationResult.rows.some(row =>
|
|
row.version === MIGRATION_V3 && row.checksum === MIGRATION_V3_CHECKSUM
|
|
)
|
|
};
|
|
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: Object.values(checks).every(Boolean),
|
|
targetSchema: TARGET_SCHEMA,
|
|
checks,
|
|
existingCatalogFingerprint: existingFingerprint,
|
|
targetSummary: {
|
|
tableCount: Object.keys(actualColumns).length,
|
|
indexCount: actualIndexes.length,
|
|
constraintCount: constraintResult.rows[0].constraint_count,
|
|
functionCount: functionResult.rowCount,
|
|
roomTypeCount: roomTypeResult.rowCount,
|
|
businessCounts
|
|
}
|
|
}, null, 2)}\n`);
|
|
|
|
if (Object.values(checks).some(result => !result)) process.exitCode = 1;
|
|
} catch (error) {
|
|
process.stdout.write(`${JSON.stringify(sanitizeError(error), null, 2)}\n`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (client) {
|
|
if (transactionStarted) {
|
|
try {
|
|
await client.query("ROLLBACK");
|
|
} catch {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
await client.end().catch(() => {
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
}
|