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/workqueue', '/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('work queue exposes only safe progress fields behind login', async t => { const { directory, request, login } = await fixture(t); await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({ schema: 1, tasks: [ {id:'interaction-private-a', title:'PRIVATE MESSAGE CONTENT', status:'pending', details:'PRIVATE MESSAGE', checkpoint:'PRIVATE CONTENT', attempts:1}, {id:'pilot',title:'动画试镜',status:'running',details:'SECRET',attempts:2,updated_at:'2026-11-04T01:00:00Z'} ] })); assert.equal((await request('/api/workqueue')).status, 401); const auth = await login(); const cookie = auth.headers.get('set-cookie').split(';')[0]; const response = await request('/api/workqueue', { headers: { cookie } }); const body = await response.text(); assert.doesNotMatch(body, /PRIVATE|SECRET/); const data = JSON.parse(body); assert.equal(data.tasks[0].title, '核查新互动'); assert.equal(data.tasks[1].status, 'running'); }); 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: [] }); }); test('daily records require authentication, sort by date, validate input and remain read-only', async t => { const { directory, request, login } = await fixture(t); await mkdir(path.join(directory, 'daily')); const write = (name, value) => writeFile(path.join(directory, 'daily', name), value); await write('2026-11-02.json', JSON.stringify({ date: '2026-11-02', planned: [''], costs: ['未知'] })); await write('2026-11-03.json', JSON.stringify({ date: '2026-11-03', executed: ['完成'], evidence: ['证据'] })); await write('2026-11-04.json', '{broken'); await write('2026-11-05.json', JSON.stringify({ date: '2026-11-05', planned: 'wrong type' })); await write('2026-02-30.json', JSON.stringify({ date: '2026-02-30' })); await write('ignored.json', 'bad'); const anonymous = await request('/api/daily'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /private|完成/); assert.equal((await request('/daily/2026-11-02.json')).status, 404); const cookie = (await login()).headers.get('set-cookie').split(';')[0]; const headers = { Cookie: cookie }; const result = await request('/api/daily', { headers }); assert.equal(result.status, 200); const body = await result.json(); assert.equal(body.invalid_count, 3); assert.deepEqual(body.records.map(r => r.date), ['2026-11-03', '2026-11-02']); assert.deepEqual(body.records[0].planned, []); assert.deepEqual(body.records[0].executed, ['完成']); assert.deepEqual(body.records[1].costs, ['未知']); assert.equal((await request('/api/daily', { method: 'POST', headers: { ...headers, Origin: origin }, body: '{}' })).status, 404); await rm(path.join(directory, 'daily'), { recursive: true }); assert.deepEqual(await (await request('/api/daily', { headers })).json(), { records: [], invalid_count: 0 }); }); test('daily symlinks cannot disclose files outside DATA_DIR', async t => { const { directory, request, login } = await fixture(t); const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-daily-outside-')); t.after(() => rm(outside, { recursive: true, force: true })); await mkdir(path.join(directory, 'daily')); await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ date: '2026-11-03', executed: ['OUTSIDE-SECRET'] })); await symlink(path.join(outside, 'secret.json'), path.join(directory, 'daily/2026-11-03.json')); const cookie = (await login()).headers.get('set-cookie').split(';')[0]; const response = await request('/api/daily', { headers: { Cookie: cookie } }); assert.equal(response.status, 200); const text = await response.text(); assert.doesNotMatch(text, /OUTSIDE-SECRET/); assert.deepEqual(JSON.parse(text), { records: [], invalid_count: 1 }); }); test('overview is authenticated and exposes only selected fields', async t => { const { directory, request, login } = await fixture(t); await writeFile(path.join(directory, 'ops-strategy.json'), JSON.stringify({ mission:'星栖', private:'SECRET' })); await writeFile(path.join(directory, 'market-latest.json'), JSON.stringify({ observed_at:'2026-11-04', ranking:[{bvid:'https://evil.test',title:'榜单',views:1,private:'SECRET'}],account_videos:[] })); assert.equal((await request('/api/overview')).status, 401); const cookie = (await login()).headers.get('set-cookie').split(';')[0]; const response = await request('/api/overview', { headers:{cookie} }); const text = await response.text(); assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text); assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, ''); }); test('execution status is private and absent data degrades safely', async t => { const { directory, request, login } = await fixture(t); await writeFile(path.join(directory, 'ops-status.json'), JSON.stringify({schema:1,observed_at:'2026-11-04T04:00:00Z',units:[],running_tasks:[],history:[{title:'任务',status:'complete'}]})); assert.equal((await request('/api/execution')).status, 401); const cookie = (await login()).headers.get('set-cookie').split(';')[0]; const data = await (await request('/api/execution', { headers:{cookie} })).json(); assert.equal(data.history[0].title, '任务'); assert.equal(data.history[0].status, 'complete'); }); test('waiting queue tasks remain visible to the owner', async t => { const { directory, request, login } = await fixture(t); await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({schema:1,tasks:[{id:'idea',title:'等待素材',status:'waiting'}]})); const cookie=(await login()).headers.get('set-cookie').split(';')[0]; const result=await (await request('/api/workqueue',{headers:{cookie}})).json(); assert.equal(result.tasks[0].status,'waiting'); }); test('legacy pending tasks are not presented as ready to dispatch', async t => { const { directory, request, login } = await fixture(t); await writeFile(path.join(directory,'workqueue.json'),JSON.stringify({schema:1,tasks:[{id:'old',title:'旧任务',status:'pending'}]})); const cookie=(await login()).headers.get('set-cookie').split(';')[0]; const value=await (await request('/api/workqueue',{headers:{cookie}})).json(); assert.equal(value.tasks[0].status,'waiting'); });