Files
behind_glass/scripts/test-arrival-api.mjs
T
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

209 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../server/app.mjs';
import { openStore } from '../server/db.mjs';
const VISITOR = '12345678-1234-4123-8123-123456789abc';
const DOG = 'a'.repeat(64);
const ORIGIN = 'https://arrival.example';
async function fixture(t, options = {}) {
const root = mkdtempSync(join(tmpdir(), 'arrival-api-'));
const distRoot = join(root, 'dist');
mkdirSync(join(distRoot, 'assets'), { recursive: true });
mkdirSync(join(distRoot, 'fonts'));
mkdirSync(join(distRoot, 'models'));
writeFileSync(join(distRoot, 'index.html'), '<!doctype html><title>arrival fixture</title>');
writeFileSync(join(distRoot, 'assets', 'index-AbCd1234.js'), 'export const loaded = true');
writeFileSync(join(distRoot, 'assets', 'worker-ZyXw9876.js'), 'self.onmessage = () => {}');
writeFileSync(join(distRoot, 'assets', 'ort-wasm.wasm'), Buffer.from([0, 97, 115, 109]));
writeFileSync(join(distRoot, 'fonts', 'font.woff'), 'font');
writeFileSync(join(distRoot, 'models', 'model.onnx'), 'model');
writeFileSync(join(distRoot, 'config.json'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite-wal'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite-shm'), 'private-fixture-secret');
writeFileSync(join(root, 'outside.txt'), 'private-fixture-secret');
symlinkSync(join(root, 'outside.txt'), join(distRoot, 'assets', 'escaped.txt'));
const store = options.store ?? openStore({ dbPath: join(root, 'private', 'counts.sqlite') });
const app = await buildApp({ store, distRoot, publicOrigin: ORIGIN, rateLimit: { max: 10000, timeWindow: 60000 }, ...options });
t.after(async () => { await app.close(); rmSync(root, { recursive: true, force: true }); });
return { app, store, root };
}
function post(app, url, payload, headers = {}) { return app.inject({ method: 'POST', url, payload, headers }); }
function assertError(response, status) {
assert.equal(response.statusCode, status, response.body);
assert.match(response.headers['content-type'], /application\/json/);
assert.equal(response.headers['cache-control'], 'no-store');
assert.doesNotMatch(response.body, /12345678|aaaaaaaaaa|sqlite|private-fixture-secret|visitor_id|dog_id|body\//i);
}
test('visit and arrival contracts use real SQLite and concurrent duplicates stay at one', async t => {
const { app, store } = await fixture(t);
assert.deepEqual((await app.inject('/api/arrivals/count')).json(), { count: 0 });
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR })).statusCode, 204);
const replies = await Promise.all(Array.from({ length: 20 }, () => post(app, '/api/arrivals', { dog_id: DOG })));
for (const reply of replies) { assert.equal(reply.statusCode, 204); assert.equal(reply.body, ''); }
assert.equal(store.arrivalCount(), 1);
const count = await app.inject('/api/arrivals/count');
assert.deepEqual(count.json(), { count: 1 });
assert.equal(count.headers['cache-control'], 'no-store');
});
test('exact schemas reject extra keys, coercion, invalid IDs, array/null and malformed JSON', async t => {
const { app } = await fixture(t);
for (const payload of [{}, { visitor_id: 1 }, { visitor_id: null }, { visitor_id: [VISITOR] }, { visitor_id: VISITOR, photo: 'private-fixture-secret' }, { visitor_id: VISITOR, name: 'private-fixture-secret' }, { visitor_id: VISITOR, visit_date: '2026-10-08' }, [], null]) {
assertError(await post(app, '/api/visits', JSON.stringify(payload), { 'content-type': 'application/json' }), 400);
}
for (const id of ['x', VISITOR.replace('-4', '-1'), VISITOR.replace('-8123', '-7123'), `${VISITOR}\n`, VISITOR.toUpperCase()]) assertError(await post(app, '/api/visits', { visitor_id: id }), 400);
for (const payload of [{}, { dog_id: 1 }, { dog_id: DOG.toUpperCase() }, { dog_id: DOG.slice(1) }, { dog_id: `${DOG}\n` }, { dog_id: DOG, photo: 'private-fixture-secret' }, { dog_id: DOG, name: 'private-fixture-secret' }, { dog_id: DOG, visitor_id: VISITOR }]) assertError(await post(app, '/api/arrivals', payload), 400);
assertError(await post(app, '/api/arrivals', '{"dog_id":', { 'content-type': 'application/json' }), 400);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG }), { 'content-type': 'text/plain' }), 415);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG }), { 'content-type': 'application/x-www-form-urlencoded' }), 415);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG, extra: 'x'.repeat(1024) }), { 'content-type': 'application/json' }), 413);
});
test('Origin validation uses configured public origin and allows absent Origin', async t => {
const { app } = await fixture(t);
for (const origin of [undefined, ORIGIN]) {
const headers = origin ? { origin } : {};
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR }, headers)).statusCode, 204);
}
for (const origin of ['https://foreign.example', 'null', 'not-an-origin', `${ORIGIN}/path`, `${ORIGIN}, https://foreign.example`]) {
assertError(await post(app, '/api/visits', { visitor_id: VISITOR }, { origin, host: 'foreign.example', 'x-forwarded-host': 'foreign.example' }), 403);
}
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR }, { origin: ORIGIN, host: 'forged.example' })).statusCode, 204);
});
test('API rate limit is 60 per connection IP; forged forwarded headers do not evade it', async t => {
const { app } = await fixture(t, { rateLimit: { max: 60, timeWindow: 60000 } });
for (let i = 0; i < 60; i++) assert.equal((await app.inject({ url: '/api/arrivals/count', headers: { 'x-forwarded-for': `203.0.113.${i}` } })).statusCode, 200);
assertError(await app.inject({ url: '/api/arrivals/count', headers: { 'x-forwarded-for': '198.51.100.99' } }), 429);
assert.equal((await app.inject({ url: '/api/arrivals/count', remoteAddress: '192.0.2.12' })).statusCode, 200);
assert.equal((await app.inject('/')).statusCode, 200);
});
test('closed/unavailable store returns generic 503 without identifiers or paths', async t => {
const { app, store } = await fixture(t);
store.close();
assertError(await app.inject('/api/arrivals/count'), 503);
assertError(await post(app, '/api/visits', { visitor_id: VISITOR }), 503);
assertError(await post(app, '/api/arrivals', { dog_id: DOG }), 503);
});
test('app close owns its store exactly once and import never listens', async t => {
let closed = 0;
const { app } = await fixture(t, { store: { close() { closed++; }, arrivalCount() { return 0; } } });
assert.equal(app.server.listening, false);
await app.close();
await app.close();
assert.equal(closed, 1);
});
test('unknown API and POST routes return JSON 404, never SPA HTML', async t => {
const { app } = await fixture(t);
for (const method of ['GET', 'POST']) for (const url of ['/api', '/api/', '/api/nope', '/api/monthly-visitors', '/api/visits/nope']) assertError(await app.inject({ method, url, headers: { accept: 'text/html' } }), 404);
assertError(await app.inject({ method: 'POST', url: '/somewhere', headers: { accept: 'text/html' } }), 404);
});
test('static MIME/cache and SPA navigation preserve worker/WASM/font/model asset routes', async t => {
const { app } = await fixture(t);
for (const url of ['/', '/index.html', '/journey/window']) {
const response = await app.inject({ url, headers: { accept: 'text/html' } });
assert.equal(response.statusCode, 200);
assert.match(response.body, /arrival fixture/);
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal((await app.inject({ method: 'HEAD', url: '/journey/window', headers: { accept: 'text/html' } })).statusCode, 200);
for (const url of ['/assets/index-AbCd1234.js', '/assets/worker-ZyXw9876.js']) {
const response = await app.inject(url);
assert.equal(response.statusCode, 200);
assert.match(response.headers['content-type'], /javascript/);
assert.equal(response.headers['cache-control'], 'public,max-age=31536000,immutable');
}
const wasm = await app.inject('/assets/ort-wasm.wasm');
assert.equal(wasm.statusCode, 200);
assert.equal(wasm.headers['content-type'], 'application/wasm');
assert.equal(wasm.headers['cache-control'], 'no-cache');
for (const url of ['/fonts/font.woff', '/models/model.onnx']) assert.equal((await app.inject(url)).statusCode, 200);
for (const url of ['/assets/nope.js', '/models/missing.onnx', '/route.json', '/missing']) assertError(await app.inject({ url, headers: { accept: url === '/missing' ? '*/*' : 'text/html' } }), 404);
});
test('traversal, symlink escapes and guessed data/config files are never static responses', async t => {
const { app } = await fixture(t);
for (const url of ['/../outside.txt', '/%2e%2e/outside.txt', '/%2e%2e%2foutside.txt', '/assets/%2e%2e/%2e%2e/outside.txt', '/assets/escaped.txt', '/counts.sqlite', '/counts.sqlite-wal', '/counts.sqlite-shm', '/config.json', '/.env', '/.git/config', '/server/config.mjs', '/api/%2e%2e%2findex.html']) {
const response = await app.inject({ url, headers: { accept: 'text/html' } });
assert.ok([400, 404].includes(response.statusCode), `${url}: ${response.statusCode}`);
assert.doesNotMatch(response.body, /private-fixture-secret|arrival fixture/);
}
});
test('unhashed existing GLB assets never receive an immutable build cache policy', async t => {
const { app, root } = await fixture(t);
const directory = join(root, 'dist', 'assets', 'window');
mkdirSync(directory);
writeFileSync(join(directory, 'grandma-house-surround.glb'), 'geometry fixture');
const response = await app.inject('/assets/window/grandma-house-surround.glb?v=2');
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-cache');
});
test('percent-encoded API route aliases share the same IP limit, including writes', async t => {
const { app, store } = await fixture(t, { rateLimit: { max: 1, timeWindow: 60000 } });
assert.equal((await post(app, '/api/arrivals', { dog_id: DOG })).statusCode, 204);
for (const url of ['/%61pi/arrivals', '/a%70i/arrivals', '/ap%69/arrivals']) assertError(await post(app, url, { dog_id: 'f'.repeat(64) }), 429);
assert.equal(store.arrivalCount(), 1);
assertError(await app.inject('/%61pi/arrivals/count'), 429);
});
test('framework malformed-URL errors never echo request identifiers and are no-store', async t => {
const { app } = await fixture(t);
const response = await app.inject('/api/%ZZ/private-fixture-secret');
assertError(response, 400);
assert.doesNotMatch(response.body, /%ZZ|private-fixture-secret|FST_ERR_BAD_URL/);
});
test('same-root symlink aliases cannot expose blocked database or configuration files', async t => {
const { app, root } = await fixture(t);
for (const [name, target] of [['alias.txt', 'counts.sqlite'], ['config-alias.txt', 'config.json'], ['wal-alias.txt', 'counts.sqlite-wal']]) {
symlinkSync(join(root, 'dist', target), join(root, 'dist', name));
const response = await app.inject(`/${name}`);
assertError(response, 404);
}
});
test('unknown API paths return JSON 404 before unsupported or malformed body parsing', async t => {
const { app } = await fixture(t);
for (const url of ['/api', '/api/nope', '/api/visits/nope']) {
assertError(await post(app, url, 'private-fixture-secret', { 'content-type': 'text/plain' }), 404);
assertError(await post(app, url, '{"private-fixture-secret":', { 'content-type': 'application/json' }), 404);
}
});
test('absolute-form HTTP targets cannot bypass same-origin or API rate guards', async t => {
const { app, store } = await fixture(t, { rateLimit: { max: 1, timeWindow: 60000 } });
const { default: inject } = await import('light-my-request');
for (const origin of ['https://foreign.example', ORIGIN]) {
const response = await inject((request, reply) => {
request.url = 'http://arrival.example/api/arrivals';
app.routing(request, reply);
}, { url: '/api/arrivals', method: 'POST', payload: { dog_id: DOG }, headers: { origin } });
assertError(response, 400);
}
assert.equal(store.arrivalCount(), 0);
});
test('raw unnormalized traversal is rejected before the routing/static boundary', async t => {
const { app } = await fixture(t);
const { default: inject } = await import('light-my-request');
for (const target of ['/api/%2e%2e/index.html', '/../index.html', '/assets/../index.html']) {
const response = await inject((request, reply) => {
request.url = target;
app.routing(request, reply);
}, { url: '/', headers: { accept: 'text/html' } });
assertError(response, 404);
}
});