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>
This commit is contained in:
parent
2434879804
commit
c14081d85f
14 changed files with 2306 additions and 0 deletions
108
server/test/labels.test.ts
Normal file
108
server/test/labels.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue