Files
wyndham-Condon/backend/scripts/verify-deployment.mjs
2026-08-02 13:33:13 +08:00

416 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 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" },
ensure_booking_for_usage: { volatility: "v" },
open_entitlement_period: { volatility: "v" }
};
const functionsValid =
functionResult.rowCount === 5
&& 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 === 2
&& 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
)
};
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;
});
}
}