Groundwork only. Nothing reads any of this yet, and no behaviour changes. The Relying Party ID is derived from PUBLIC_URL rather than written down, because it is the one value in this feature that cannot be corrected afterwards: a credential is bound to it permanently, and a wrong one surfaces only as a customer unable to sign in with a passkey that no longer matches anything. PUBLIC_URL is what every customer-facing link is already built from, so the ID is correct wherever those links are, and wrong only where they were already wrong. hostname rather than host, so a port cannot reach an ID that must not contain one. Local development is the exception the issue's table did not cover. envValidation requires PUBLIC_URL only when SMTP is configured, so a local setup that cannot send mail legitimately has none and falls back to localhost, which browsers treat as a secure context. Two origins there rather than one: the app is served by Vite on 5173 during development and by Express on 3000 once built, and those differ only by port, which is not part of the RP ID. The challenge table is separate from customer_tokens, and the reason is structural rather than preference. customer_tokens.customer_id is NOT NULL, and an authentication challenge is issued before anyone is identified — a discoverable-credential sign-in has no customer to attach to at the moment the challenge exists. Storing it there would mean making that column nullable for every other kind of token. Two of the issue's open decisions are deliberately not made here, because they belong to the ceremony that enforces them rather than to the schema. What to do when the signature counter fails to increase is #39's: many synced passkeys report zero forever, so treating a non-increase as cloning is wrong for them and right for a hardware key, and this only has to hold the value. Whether a disabled account can authenticate is also #39's, and the schema takes the position that it should not cost the customer their devices: credentials survive disabling and are refused at the ceremony, so re-enabling does not mean re-registering everything. Deletion is different and is settled here — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists. signature_counter is BIGINT because the spec allows a 32-bit unsigned value, which overflows a signed INTEGER at half its range. That is the first bigint column in this schema, so the generated mirror gains the Int8 alias with it. Both tables are added to resetDb's TRUNCATE list and to REQUIRED_TABLES, and the schema mirror is updated by hand to match what kysely-codegen emits — placement and all, so a real regenerate produces no diff. Skipping either is how #56 turned a green local run into a red main; the mirror drift guard exists precisely to catch it, and schemaLoss's count moves from 18 to 20 with them. Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 494 unit tests across 34 suites including nine new ones for the RP derivation, frontend build green, and the migration parses. Not verified: the migration has not been run against a database, and the integration suite needs one this machine cannot provide. Worth knowing before this goes further: #313 changes the domain, and every passkey registered before that cutover stops working at it. This code needs no change — it follows PUBLIC_URL — but the credentials do not survive. That is free while production is not live and nobody holds one, and it stops being free the day the shop opens. Closes #37 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.6 KiB
TypeScript
Executable File
191 lines
7.6 KiB
TypeScript
Executable File
import { Pool } from 'pg';
|
|
import { runner } from 'node-pg-migrate';
|
|
import path from 'path';
|
|
|
|
export const testPool = new Pool({
|
|
host: process.env.TEST_PGHOST || 'localhost',
|
|
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
|
user: process.env.TEST_PGUSER || 'redefined_test',
|
|
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
|
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
|
});
|
|
|
|
export async function migrate(): Promise<void> {
|
|
await runner({
|
|
databaseUrl: {
|
|
host: process.env.TEST_PGHOST || 'localhost',
|
|
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
|
user: process.env.TEST_PGUSER || 'redefined_test',
|
|
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
|
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
|
},
|
|
dir: path.resolve(__dirname, '..', '..', '..', 'migrations'),
|
|
direction: 'up',
|
|
migrationsTable: 'pgmigrations',
|
|
count: Infinity
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The tables `resetDb` truncates. Also, therefore, exactly the set whose absence
|
|
* makes this suite meaningless.
|
|
*/
|
|
const REQUIRED_TABLES = [
|
|
'admin_settings',
|
|
'cart_items',
|
|
'carts',
|
|
'categories',
|
|
'checkout_items',
|
|
'checkouts',
|
|
'customer_credentials',
|
|
'customer_sessions',
|
|
'customer_tokens',
|
|
'customers',
|
|
'favorites',
|
|
'item_drafts',
|
|
'item_images',
|
|
'item_tags',
|
|
'items',
|
|
'orders',
|
|
'shipping_addresses',
|
|
'tags',
|
|
'upload_links',
|
|
'webauthn_challenges'
|
|
] as const;
|
|
|
|
/** Which of them the database does not currently have. */
|
|
async function missingTables(): Promise<string[]> {
|
|
const { rows } = await testPool.query<{ table_name: string }>(
|
|
`SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`
|
|
);
|
|
const present = new Set(rows.map((row) => row.table_name));
|
|
return REQUIRED_TABLES.filter((name) => !present.has(name));
|
|
}
|
|
|
|
/**
|
|
* Says plainly when the schema has gone, rather than letting the suite report
|
|
* it as unrelated logic failures.
|
|
*
|
|
* In #154 the integration run lost its schema partway through — a Postgres
|
|
* service container recreated mid-run comes back with an empty data directory —
|
|
* and it presented as 36 assertion errors about categories, price filters and
|
|
* favourite notifications. The real message, `relation "items" does not exist`,
|
|
* was further down the same log. Hours went into chasing the assertions.
|
|
*
|
|
* The cause is still open and needs runner-side evidence. This is the half that
|
|
* can be fixed from here: whatever the cause, the next occurrence should read as
|
|
* "the database lost its schema" on the first line.
|
|
*/
|
|
/**
|
|
* Which Postgres actually answered, rather than which one we asked for (#154).
|
|
*
|
|
* The reason this exists: the schema in a failing run **comes back**. A suite
|
|
* fails because `orders` does not exist, and a later suite truncating the same
|
|
* table passes. A dropped database does not un-drop itself, so more than one
|
|
* server must be answering to the same name — Docker's embedded DNS round-robins
|
|
* every container sharing an alias, so a leftover service container from an
|
|
* earlier run would produce exactly this.
|
|
*
|
|
* `pg_postmaster_start_time()` is what settles it and needs no special rights:
|
|
* two Postgres instances cannot share one. Differing values across a single run
|
|
* are proof outright, where a differing `inet_server_addr` alone could be argued
|
|
* to be one container that moved.
|
|
*
|
|
* Never throws. This is a diagnostic, and a diagnostic that can fail a run it
|
|
* was added to explain is worse than no diagnostic.
|
|
*/
|
|
export async function describeBackend(label: string): Promise<string> {
|
|
try {
|
|
const { rows } = await testPool.query<{
|
|
addr: string | null;
|
|
started: string;
|
|
pid: number;
|
|
db: string;
|
|
}>(
|
|
`SELECT inet_server_addr()::text AS addr,
|
|
pg_postmaster_start_time()::text AS started,
|
|
pg_backend_pid() AS pid,
|
|
current_database() AS db`
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return `${label}: no row returned`;
|
|
return `${label}: db=${row.db} addr=${row.addr ?? 'local'} postmaster_start=${row.started} pid=${row.pid}`;
|
|
} catch (err) {
|
|
return `${label}: could not be identified (${err instanceof Error ? err.message : String(err)})`;
|
|
}
|
|
}
|
|
|
|
export async function assertSchemaPresent(context: string): Promise<void> {
|
|
const missing = await missingTables();
|
|
if (missing.length === 0) return;
|
|
|
|
// Gathered only on the failure path, which is the one worth paying for, and
|
|
// is where #154 has repeatedly lacked the one fact that would identify it.
|
|
const backend = await describeBackend('Answering server');
|
|
|
|
throw new Error(
|
|
`The test database has no schema (${context}).
|
|
|
|
` +
|
|
`Missing ${missing.length} of ${REQUIRED_TABLES.length} tables: ${missing.join(', ')}.
|
|
|
|
` +
|
|
`${backend}
|
|
|
|
` +
|
|
`Migrations ran at the start of this run, so the schema existed and has since gone. ` +
|
|
`Nothing in this suite drops tables — TRUNCATE does not. Compare the postmaster start ` +
|
|
`time above against the one globalSetup logged: if they differ, this is a different ` +
|
|
`Postgres instance answering to the same name rather than one database losing its ` +
|
|
`schema, and the schema was never lost at all. ` +
|
|
`See #154.`
|
|
);
|
|
}
|
|
|
|
export async function resetDb(): Promise<void> {
|
|
// The schema check runs only when the truncate fails, not on every reset.
|
|
// resetDb runs in a beforeEach several hundred times a suite, and an extra
|
|
// round trip each time to guard against something that has happened once
|
|
// would be paying continuously for a rare event. A failure here is where the
|
|
// information is worth having.
|
|
try {
|
|
await testPool.query(`
|
|
TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts,
|
|
shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites,
|
|
webauthn_challenges, customer_credentials,
|
|
customers, item_tags, item_images, items, tags, categories
|
|
RESTART IDENTITY CASCADE
|
|
`);
|
|
} catch (err) {
|
|
await assertSchemaPresent('while resetting between tests');
|
|
// The schema is there, so this is not #154 — let the original speak.
|
|
throw err;
|
|
}
|
|
|
|
// admin_settings is not truncated — it holds the seeded cart_expiry_hours
|
|
// default that other suites read. But the email template rows in it are test
|
|
// data like any other, and a stored template outliving the suite that wrote
|
|
// it silently changes the mail every later suite asserts on. That is not
|
|
// hypothetical: a subject of "Gone" written by the template tests reached the
|
|
// favorite-alert tests and made five of them fail somewhere else entirely.
|
|
//
|
|
// Same reasoning, same failure, for the intake settings (#227): a ceiling of
|
|
// 1 left behind by the ceiling suite makes every later submission refuse with
|
|
// a 503, in files that never mention a ceiling.
|
|
//
|
|
// ESCAPE, and not a backslash, because the backslash that used to be here did
|
|
// nothing. In a JavaScript string 'email\_%' is 'email_%', and `_` in SQL LIKE
|
|
// matches any single character — so the pattern meant "email plus any one
|
|
// character", not "email_". It happened to delete the right rows only because
|
|
// no other key begins with those letters followed by something else; a
|
|
// setting called emailing_enabled would have been swept away silently. Found
|
|
// by no-useless-escape the first time lint was pointed at tests/ (#298).
|
|
await testPool.query(
|
|
`DELETE FROM admin_settings WHERE key LIKE 'email!_%' ESCAPE '!' OR key LIKE 'intake!_%' ESCAPE '!'`
|
|
);
|
|
}
|
|
|
|
export async function closeDb(): Promise<void> {
|
|
await testPool.end();
|
|
} |