diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 8add5e0e..d8cb3d3b 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -7,7 +7,8 @@ "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", "build": "node ./build.mjs", "start": "node --enable-source-maps ./dist/index.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test 'tests/**/*.test.mjs'" }, "dependencies": { "@google-cloud/storage": "^7.19.0", @@ -35,6 +36,7 @@ "@types/node": "catalog:", "esbuild": "^0.27.3", "esbuild-plugin-pino": "^2.3.3", + "pg": "^8.20.0", "pino-pretty": "^13", "thread-stream": "3.1.0" } diff --git a/artifacts/api-server/tests/apps-open.test.mjs b/artifacts/api-server/tests/apps-open.test.mjs new file mode 100644 index 00000000..1988b0b3 --- /dev/null +++ b/artifacts/api-server/tests/apps-open.test.mjs @@ -0,0 +1,180 @@ +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)}`; + + 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", + ); +}); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index f07cb098..d2459eb4 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdcacc23..91dde0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: esbuild-plugin-pino: specifier: ^2.3.3 version: 2.3.3(esbuild@0.27.3)(pino-pretty@13.1.3)(pino@9.14.0)(thread-stream@3.1.0) + pg: + specifier: ^8.20.0 + version: 8.20.0 pino-pretty: specifier: ^13 version: 13.1.3