import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import pg from "pg"; 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 TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); let testUserId; let testUsername; let validAppId; let validAppRoute; let badAppId; let sessionCookie; async function loginAndGetCookie(username, password) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200, `login expected 200, got ${res.status}`); const setCookie = res.headers.get("set-cookie"); assert.ok(setCookie, "expected Set-Cookie header from login"); const sid = setCookie.split(",").map((c) => c.split(";")[0].trim()).find((c) => c.startsWith("connect.sid="), ); assert.ok(sid, "expected connect.sid cookie"); return sid; } async function countOpens({ userId, appId } = {}) { let query = "SELECT COUNT(*)::int AS c FROM app_opens WHERE 1=1"; const params = []; if (userId !== undefined) { params.push(userId); query += ` AND user_id = $${params.length}`; } if (appId !== undefined) { params.push(appId); query += ` AND app_id = $${params.length}`; } const { rows } = await pool.query(query, params); return rows[0].c; } before(async () => { testUsername = `open_test_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; ccaPassa, const userRes = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`, [testUsername, `${testUsername}@example.com`, TEST_PASSWORD_HASH], ); testUserId = userRes.rows[0].id; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [testUserId], ); const appRes = await pool.query( `SELECT id, route FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`, ); assert.ok(appRes.rows[0], "no active app found to test against"); validAppId = appRes.rows[0].id; validAppRoute = appRes.rows[0].route; const maxRes = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`); badAppId = Number(maxRes.rows[0].m) + 100000; sessionCookie = await loginAndGetCookie(testUsername, TEST_PASSWORD); }); after(async () => { if (testUserId !== undefined) { await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [testUserId]); await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [testUserId]); await pool.query(`DELETE FROM users WHERE id = $1`, [testUserId]); } await pool.end(); }); test("happy path: authenticated POST inserts a row and returns 204", async () => { const before = await countOpens({ userId: testUserId, appId: validAppId }); const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, { method: "POST", headers: { Cookie: sessionCookie }, }); assert.equal(res.status, 204); const after = await countOpens({ userId: testUserId, appId: validAppId }); assert.equal(after, before + 1, "app_opens row should be inserted"); }); test("unauthenticated POST returns 401 and inserts no row", async () => { const before = await countOpens({ appId: validAppId }); const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, { method: "POST", }); assert.equal(res.status, 401); const after = await countOpens({ appId: validAppId }); assert.equal(after, before, "no row should be inserted on 401"); }); test("authenticated POST to non-existent app id returns 404 and inserts no row", async () => { const before = await countOpens({ appId: badAppId }); const res = await fetch(`${API_BASE}/api/apps/${badAppId}/open`, { method: "POST", headers: { Cookie: sessionCookie }, }); assert.equal(res.status, 404); const after = await countOpens({ appId: badAppId }); assert.equal(after, before); assert.equal(after, 0); }); // Simulates a slow network where the client navigates away (and the response // is never consumed) before the server finishes. The fire-and-forget POST in // home.tsx uses keepalive so the request reaches the server; the server-side // handler awaits the DB insert before responding, so the row must still be // recorded even if the client aborts before reading the response. test("slow network: row is still inserted when the client aborts before reading the response", async () => { const before = await countOpens({ userId: testUserId, appId: validAppId }); await new Promise((resolve, reject) => { const url = new URL(`${API_BASE}/api/apps/${validAppId}/open`); const req = http.request( { method: "POST", hostname: url.hostname, port: url.port, path: url.pathname, headers: { Cookie: sessionCookie, "Content-Length": "0", }, }, (res) => { // If we get a response back quickly, just consume and resolve. res.resume(); res.on("end", resolve); }, ); req.on("error", (err) => { // ECONNRESET from our destroy() below is expected. if (err && (err.code === "ECONNRESET" || err.code === "ECONNABORTED")) { resolve(); } else { reject(err); } }); req.end(); // Abort the connection ~50ms after sending so the client never reads the // response, mimicking a navigation-aborted keepalive request. setTimeout(() => req.destroy(), 50); }); // Give the server time to finish its insert even though we abandoned the // socket on the client side. await new Promise((r) => setTimeout(r, 1500)); const after = await countOpens({ userId: testUserId, appId: validAppId }); assert.equal( after, before + 1, "row should be inserted server-side even when the client aborts before reading the response", ); });