import { test, after, before } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; // IMPORTANT: this file mutates shared tables (system_settings, user_roles) // during snapshot/restore. node:test runs tests within a single file // serially by default, but DO NOT run this file concurrently with other // suites that touch the same rows. The repository-level `test` workflow // runs api-server tests as one `node --test 'tests/**/*.test.mjs'` invocation // which executes files in parallel — keep this file in its own logical // group (already enforced by the snapshot/restore around every assertion) // or migrate to a dedicated test schema if you add adjacent suites that // also touch system_settings. const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) throw new Error("DATABASE_URL must be set to run these tests"); const pool = new pg.Pool({ connectionString: DATABASE_URL }); // ----- snapshot + restore helpers ------------------------------------------------- // The setup-wizard test needs to run against a DB that has NO admin and // `system_settings.installed=false`. The shared dev DB already has both // (seed runs at boot), so we snapshot the relevant rows, blank them // out, run the test, then restore exactly what was there. let snapshot = { systemSettings: null, adminUserIds: [], adminUserRoleRows: [], createdUsernames: [], }; const TEST_USERNAME = `setupwiz_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; const TEST_EMAIL = `${TEST_USERNAME}@tx.local`; before(async () => { // Snapshot system_settings. const sysRow = await pool.query("SELECT * FROM system_settings WHERE id = 1"); snapshot.systemSettings = sysRow.rows[0] ?? null; // Snapshot every user with the admin role + their user_roles edges. const adminRow = await pool.query( "SELECT id FROM roles WHERE name = 'admin' LIMIT 1", ); if (adminRow.rowCount === 0) { throw new Error("admin role missing in DB — seed first"); } const adminRoleId = adminRow.rows[0].id; const adminUsers = await pool.query( `SELECT u.id FROM users u JOIN user_roles ur ON ur.user_id = u.id WHERE ur.role_id = $1`, [adminRoleId], ); snapshot.adminUserIds = adminUsers.rows.map((r) => r.id); // Detach admin role from those users so the open-setup gate kicks in. if (snapshot.adminUserIds.length > 0) { const detached = await pool.query( `DELETE FROM user_roles WHERE role_id = $1 AND user_id = ANY($2::int[]) RETURNING user_id, role_id`, [adminRoleId, snapshot.adminUserIds], ); snapshot.adminUserRoleRows = detached.rows; } // Wipe install flag so the wizard treats this as a fresh install. await pool.query("DELETE FROM system_settings WHERE id = 1"); }); after(async () => { // Remove any user the test created. if (snapshot.createdUsernames.length > 0) { await pool.query( "DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", [snapshot.createdUsernames], ); await pool.query( "DELETE FROM user_groups WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", [snapshot.createdUsernames], ); await pool.query("DELETE FROM users WHERE username = ANY($1::text[])", [ snapshot.createdUsernames, ]); } // Restore system_settings to its pre-test state. await pool.query("DELETE FROM system_settings WHERE id = 1"); if (snapshot.systemSettings) { const r = snapshot.systemSettings; await pool.query( `INSERT INTO system_settings (id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8)`, [ r.installed, r.installed_at, r.base_url, r.local_domain, r.local_ip, r.https_mode, r.app_version, r.updated_at, ], ); } // Re-attach the admin role to the original admin users. if (snapshot.adminUserRoleRows.length > 0) { for (const row of snapshot.adminUserRoleRows) { await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [row.user_id, row.role_id], ); } } await pool.end(); }); test("GET /api/setup/status reports setupRequired=true on a fresh DB", async () => { const res = await fetch(`${API_BASE}/api/setup/status`); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.installed, false); assert.equal(body.setupRequired, true); assert.equal(body.checks.db, "ok"); assert.ok(typeof body.appVersion === "string"); }); test("POST /api/setup/validate returns ok for a clean payload", async () => { const res = await fetch(`${API_BASE}/api/setup/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin: { username: TEST_USERNAME, email: TEST_EMAIL, password: "ValidPass123!", }, }), }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.ok, true); }); test("POST /api/setup/validate returns 400 + per-field errors for invalid input", async () => { const res = await fetch(`${API_BASE}/api/setup/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin: { username: "x", email: "not-an-email", password: "short" }, }), }); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.ok, false); assert.ok(body.errors); }); test("POST /api/setup/complete creates the admin and flips installed", async () => { const res = await fetch(`${API_BASE}/api/setup/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin: { username: TEST_USERNAME, email: TEST_EMAIL, password: "ValidPass123!", displayNameEn: "Wizard Test Admin", }, baseUrl: "https://tx.test.local", localDomain: "tx.test.local", localIp: "192.0.2.42", httpsMode: "local", }), }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.ok, true); snapshot.createdUsernames.push(TEST_USERNAME); // Verify directly in DB. const userRow = await pool.query( "SELECT id FROM users WHERE username = $1", [TEST_USERNAME], ); assert.equal(userRow.rowCount, 1); const newId = userRow.rows[0].id; const roleRow = await pool.query( `SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = $1 AND r.name = 'admin'`, [newId], ); assert.equal(roleRow.rowCount, 1); const sys = await pool.query("SELECT * FROM system_settings WHERE id = 1"); assert.equal(sys.rows[0].installed, true); assert.equal(sys.rows[0].local_domain, "tx.test.local"); assert.equal(sys.rows[0].https_mode, "local"); assert.ok(sys.rows[0].installed_at); }); test("Second POST /api/setup/complete returns 409 already_installed", async () => { const res = await fetch(`${API_BASE}/api/setup/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin: { username: `${TEST_USERNAME}_again`, email: `${TEST_USERNAME}_again@tx.local`, password: "ValidPass123!", }, }), }); assert.equal(res.status, 409); const body = await res.json(); assert.equal(body.error, "already_installed"); }); test("POST /api/setup/validate returns 409 once installed", async () => { const res = await fetch(`${API_BASE}/api/setup/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin: { username: "ignored", email: "ignored@tx.local", password: "ValidPass123!", }, }), }); assert.equal(res.status, 409); }); test("GET /api/setup/status reports setupRequired=false after install", async () => { const res = await fetch(`${API_BASE}/api/setup/status`); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.installed, true); assert.equal(body.setupRequired, false); }); // Contract test for redirectIfSetupNeeded(): Stage 2 SPA routing depends on // this helper returning a {shouldRedirect, target, status} shape with a // /setup target only when setup is open. Asserted via /api/setup/status // since the helper is the source of truth for that response. test("redirectIfSetupNeeded contract: shouldRedirect=false post-install", async () => { // We're past the "complete" test, so installed=true. Status must reflect // that setup is closed; Stage 2's hook reads the same payload. const res = await fetch(`${API_BASE}/api/setup/status`); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.installed, true); assert.equal(body.setupRequired, false); // The redirect-decision contract: shouldRedirect MUST be false whenever // installed=true OR setupRequired=false. Stage 2's hook must mirror this. const shouldRedirect = body.checks.db === "ok" && !body.installed && body.setupRequired; assert.equal(shouldRedirect, false); }); // Regression: scripts/src/seed.ts inserts a bootstrap (id=1, installed=false) // row early. The seeded-admin branch later flips installed=true. The flip // MUST be a DO UPDATE — a DO NOTHING would silently leave installed=false // because the row already exists. This test exercises the same SQL the // seed uses so a future refactor cannot regress that behavior. test("seed flip from installed=false to installed=true uses DO UPDATE", async () => { // Save current row to restore at the end so we don't disturb other tests. const before = await pool.query("SELECT * FROM system_settings WHERE id = 1"); const beforeRow = before.rows[0] ?? null; // Step 1: bootstrap insert (mirrors seed lines 123-126). await pool.query( `INSERT INTO system_settings (id, installed) VALUES (1, false) ON CONFLICT (id) DO UPDATE SET installed = false, installed_at = NULL`, ); const mid = await pool.query( "SELECT installed FROM system_settings WHERE id = 1", ); assert.equal(mid.rows[0].installed, false, "bootstrap should leave installed=false"); // Step 2: seeded-admin branch flip (mirrors seed lines 217-227). await pool.query( `INSERT INTO system_settings (id, installed, installed_at) VALUES (1, true, NOW()) ON CONFLICT (id) DO UPDATE SET installed = true, installed_at = COALESCE(system_settings.installed_at, NOW()), updated_at = NOW()`, ); const after = await pool.query( "SELECT installed, installed_at FROM system_settings WHERE id = 1", ); assert.equal(after.rows[0].installed, true, "seed flip must set installed=true"); assert.ok(after.rows[0].installed_at !== null, "installed_at must be set"); // Restore. if (beforeRow) { await pool.query( `INSERT INTO system_settings (id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET installed = EXCLUDED.installed, installed_at = EXCLUDED.installed_at, base_url = EXCLUDED.base_url, local_domain = EXCLUDED.local_domain, local_ip = EXCLUDED.local_ip, https_mode = EXCLUDED.https_mode, app_version = EXCLUDED.app_version, updated_at = EXCLUDED.updated_at`, [ beforeRow.installed, beforeRow.installed_at, beforeRow.base_url, beforeRow.local_domain, beforeRow.local_ip, beforeRow.https_mode, beforeRow.app_version, beforeRow.updated_at, ], ); } });