BigBrainParking/server/test/labels.test.ts
Erik c14081d85f server: zone-labels API (Fastify + SQLite) — Phase A
New server/ service classifying zones as free_2h/3h/4h street vs pay_immediate
lots. Public reads; writes require the admin bearer token (timing-safe compare).
@fastify/rate-limit (120/min global, 20/min writes), manual IP denylist +
auto-block on repeated auth failures. SQLite via better-sqlite3. Dockerfile +
compose (loopback-only, mem/cpu capped) + nginx block + README. 9 tests pass.

Deployed live at https://bigbrainparking.mowden.top (behind nginx + certbot).
Not an npm workspace — kept out of the app/CI install to avoid the native dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:09:03 +00:00

108 lines
4.1 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
test('reads are public; writes require the admin token', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().labels, []);
r = await app.inject({ method: 'PUT', url: '/api/labels/113165', payload: { kind: 'free_2h' } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/labels/113165',
headers: auth,
payload: { kind: 'free_2h', zoneName: 'DL', customerId: 217 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
assert.equal(r.json().zoneName, 'DL');
assert.equal(r.json().customerId, '217');
r = await app.inject({ method: 'GET', url: '/api/labels/113165' });
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
await app.close();
});
test('upsert replaces the kind', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_2h' } });
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'pay_immediate' } });
const r = await app.inject({ method: 'GET', url: '/api/labels/1' });
assert.equal(r.json().kind, 'pay_immediate');
await app.close();
});
test('invalid kind is rejected', async () => {
const app = await make();
const r = await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_9h' } });
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
await app.close();
});
test('unknown zone → 404 on GET and DELETE', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/nope' })).statusCode, 404);
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/nope', headers: auth })).statusCode, 404);
await app.close();
});
test('delete removes the label', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/9', headers: auth, payload: { kind: 'free_4h' } });
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/9', headers: auth })).statusCode, 200);
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/9' })).statusCode, 404);
await app.close();
});
test('whoami reflects the token', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/whoami' })).statusCode, 401);
const ok = await app.inject({ method: 'GET', url: '/api/whoami', headers: auth });
assert.equal(ok.statusCode, 200);
assert.equal(ok.json().admin, true);
await app.close();
});
test('repeated auth failures auto-block the IP (403)', async () => {
const app = await make({ authFailLimit: 3, blockCooldownMs: 60_000 });
for (let i = 0; i < 3; i++) {
await app.inject({
method: 'PUT',
url: '/api/labels/1',
headers: { authorization: 'Bearer wrong' },
payload: { kind: 'free_2h' },
});
}
const r = await app.inject({ method: 'GET', url: '/api/labels' }); // even a public read is now blocked
assert.equal(r.statusCode, 403);
await app.close();
});
test('rate limit returns 429 past the threshold', async () => {
const app = await make({ rateLimitMax: 3, rateLimitWindow: '1 minute' });
let last;
for (let i = 0; i < 5; i++) last = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(last!.statusCode, 429);
await app.close();
});
test('seeded IP denylist blocks with 403', async () => {
// app.inject uses 127.0.0.1 as the client IP
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
assert.equal((await app.inject({ method: 'GET', url: '/api/labels' })).statusCode, 403);
await app.close();
});