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>
108 lines
4.1 KiB
TypeScript
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();
|
|
});
|