Files
pawplay/ops-portal/test/server.test.mjs
T
2026-11-03 17:29:10 +08:00

128 lines
9.2 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { createApp } from '../server.mjs';
import { hashPassword, verifyPassword } from '../password.mjs';
const password = 'a-test-password-with-enough-entropy';
const passwordHash = hashPassword(password);
const origin = 'http://localhost:9999';
const config = { USERNAME: 'owner', PASSWORD_HASH: passwordHash, SESSION_SECRET: 'test-secret-with-more-than-thirty-two-characters', PUBLIC_ORIGIN: origin, COOKIE_SECURE: 'false' };
async function fixture(t, options = {}) {
const directory = await mkdtemp(path.join(tmpdir(), 'pawplay-ops-test-'));
await mkdir(path.join(directory, 'reports'));
await writeFile(path.join(directory, 'config.json'), JSON.stringify({ title: 'PRIVATE-REPORT-TITLE', budget: { billing_period: 'pending' } }));
await writeFile(path.join(directory, 'ledger.json'), JSON.stringify({ entries: [{ amount: null, basis: 'cash' }] }));
await writeFile(path.join(directory, 'reports/2026-W45.json'), JSON.stringify({ id: '2026-W45', title: 'PRIVATE-WEEK', period_end: '2026-11-08', costs: { cash_usd: null } }));
const server = createApp({ ...config, DATA_DIR: directory, ...options });
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(directory, { recursive: true, force: true }); });
const request = (url, opts) => fetch(base + url, opts);
const login = async (pwd = password) => request('/api/login', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password: pwd }) });
return { server, directory, base, request, login };
}
test('fails closed without configured credentials or HTTPS production origin', () => {
assert.throws(() => createApp({}), /USERNAME/);
assert.throws(() => createApp({ ...config, SESSION_SECRET: 'short' }), /SESSION_SECRET/);
assert.throws(() => createApp({ ...config, COOKIE_SECURE: 'true' }), /HTTPS/);
assert.throws(() => createApp({ ...config, PUBLIC_ORIGIN: 'http://example.org' }), /loopback/);
assert.throws(() => createApp({ ...config, PASSWORD_HASH: 'plaintext' }), /PASSWORD_HASH/);
});
test('password hashing uses random salts and checks a supplied password', () => {
const another = hashPassword(password); assert.notEqual(another, passwordHash);
assert.equal(verifyPassword(password, another), true);
assert.equal(verifyPassword('wrong', another), false);
});
test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => {
const { request } = await fixture(t);
for (const endpoint of ['/api/dashboard', '/api/evolution', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) {
const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store');
assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/);
}
const health = await request('/healthz'); assert.deepEqual(await health.json(), { ok: true });
assert.equal((await request('/')).status, 200);
assert.equal((await request('/reports/2026-W45.json')).status, 404);
});
test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => {
const { request, login } = await fixture(t);
const response = await login(); assert.equal(response.status, 200);
const setCookie = response.headers.get('set-cookie'); assert.match(setCookie, /HttpOnly/); assert.match(setCookie, /SameSite=Lax/);
const cookie = setCookie.split(';')[0];
const r = await request('/api/dashboard', { headers: { Cookie: cookie } });
assert.equal(r.status, 200); const d = await r.json(); assert.equal(d.latest.title, 'PRIVATE-WEEK'); assert.equal(d.ledger.entries[0].amount, null);
const download = await request('/api/reports/2026-W45/download', { headers: { Cookie: cookie } });
assert.equal(download.status, 200); assert.match(download.headers.get('content-disposition'), /attachment; filename="2026-W45.json"/);
assert.equal((await download.json()).costs.cash_usd, null);
const logout = await request('/api/logout', { method: 'POST', headers: { Cookie: cookie, Origin: origin } }); assert.equal(logout.status, 200);
assert.equal((await request('/api/dashboard', { headers: { Cookie: cookie } })).status, 401);
});
test('secure deployment cookie uses __Host prefix and Secure', async t => {
const { request } = await fixture(t, { COOKIE_SECURE: 'true', PUBLIC_ORIGIN: 'https://pawplay-ops.example.org' });
const r = await request('/api/login', { method: 'POST', headers: { Origin: 'https://pawplay-ops.example.org', 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password }) });
assert.equal(r.status, 200); assert.match(r.headers.get('set-cookie'), /^__Host-pawplay_session=/); assert.match(r.headers.get('set-cookie'), /; Secure/); assert.doesNotMatch(r.headers.get('set-cookie'), /Domain=/);
});
test('tampered session cannot access API', async t => {
const { request, login } = await fixture(t); const r = await login();
const cookie = r.headers.get('set-cookie').split(';')[0];
const mutated = cookie.slice(0, -1) + (cookie.endsWith('A') ? 'B' : 'A');
assert.equal((await request('/api/dashboard', { headers: { Cookie: mutated } })).status, 401);
});
test('cross-origin login/logout are refused and repeated bad passwords are throttled', async t => {
const { request, login } = await fixture(t);
for (const endpoint of ['/api/login', '/api/logout']) {
assert.equal((await request(endpoint, { method: 'POST', headers: { Origin: 'https://attacker.invalid' }, body: '{}' })).status, 403);
assert.equal((await request(endpoint, { method: 'POST', body: '{}' })).status, 403);
}
for (let i = 0; i < 8; i++) assert.equal((await login('wrong')).status, 401);
const denied = await login(); assert.equal(denied.status, 429); assert.equal(denied.headers.get('retry-after'), '900');
});
test('raw and encoded path traversal and symlinks never expose files outside data directory', async t => {
const { request, login, server, directory } = await fixture(t);
const r = await login(); const cookie = r.headers.get('set-cookie').split(';')[0];
for (const target of ['/api/reports/%2e%2e%2fconfig', '/api/reports/a%2fb', '/api/reports/%252e%252e', '/api/reports/%00']) {
const response = await request(target, { headers: { Cookie: cookie } }); assert.equal(response.status, 400);
}
const rawStatus = await new Promise(resolve => {
const request = http.request({ host: '127.0.0.1', port: server.address().port, path: '/api/reports/../../config.json', headers: { Cookie: cookie } }, response => { response.resume(); resolve(response.statusCode); }); request.end();
}); assert.equal(rawStatus, 400);
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ id: 'escape', secret: 'OUTSIDE-SECRET' }));
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'reports/escape.json'));
const response = await request('/api/reports/escape', { headers: { Cookie: cookie } }); assert.equal(response.status, 500); assert.doesNotMatch(await response.text(), /OUTSIDE|secret|\/tmp/);
});
test('invalid JSON values and oversized bodies are rejected', async t => {
const { request } = await fixture(t);
const headers = { Origin: origin, 'Content-Type': 'application/json' };
assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400);
assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413);
});
test('evolution is private, readable after login, and empty when absent', async t => {
const { request, login, directory } = await fixture(t);
const example = { schema_version: 1, events: [{ id: 'fictional-1', recorded_at: '2026-11-03', retrospective: true, trigger: '虚构起因', hypothesis: '虚构假设', change: '虚构改变', evidence: ['虚构证据'], cost: { known: '未知', unknown: '待确认' }, decision: '虚构决策', next_step: '虚构下一步', artifact_refs: ['虚构版本'] }] };
await writeFile(path.join(directory, 'evolution.json'), JSON.stringify(example));
const anonymous = await request('/api/evolution'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /虚构/);
const shell = await request('/'); assert.doesNotMatch(await shell.text(), /虚构/);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const authenticated = await request('/api/evolution', { headers: { Cookie: cookie } });
assert.equal(authenticated.status, 200); assert.deepEqual(await authenticated.json(), example);
await rm(path.join(directory, 'evolution.json'));
const empty = await request('/api/evolution', { headers: { Cookie: cookie } });
assert.equal(empty.status, 200); assert.deepEqual(await empty.json(), { schema_version: 1, events: [] });
});