Some checks failed
build-apk / build (push) Failing after 1h56m52s
Anonymous Mode — "Park without signing in" on the login screen (with a popup of what works vs needs a login). Anonymous users browse parking areas from our mirror, see labels, and start free check-in timers; paying, sessions, and account screens prompt to sign in. AuthContext gains an 'anonymous' status + enterAnonymous/requireLogin. Zone mirror — server gains a `zones` table + public GET /api/zones and admin POST /api/zones/sync. Signed-in admins push the zones they pull (authed) from ParkSmarter after each map search, so anonymous users can read areas without a ParkSmarter login. Map/Scan read the mirror when anonymous. Also: the free "Check in" button is now always available with a 2h/3h/4h picker (no longer gated on a prior label) — fixes "couldn't start a timer on a free zone". CORS probe confirmed ParkSmarter allows any origin but only Content-Type, so a future PWA can't auth to it — the mirror is what makes anonymous browsing (and a PWA) possible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
TypeScript
140 lines
5.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('zone mirror: admin sync then public read', async () => {
|
|
const app = await make();
|
|
// sync requires auth
|
|
let r = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/zones/sync',
|
|
payload: { zones: [{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55 }] },
|
|
});
|
|
assert.equal(r.statusCode, 401);
|
|
// authed sync (one zone lacks ZoneId → skipped)
|
|
r = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/zones/sync',
|
|
headers: auth,
|
|
payload: {
|
|
zones: [
|
|
{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55, Spaces: [{ SpaceId: 1 }] },
|
|
{ ZoneName: 'no-id' },
|
|
],
|
|
},
|
|
});
|
|
assert.equal(r.statusCode, 200);
|
|
assert.equal(r.json().synced, 1);
|
|
// public read returns full Zone objects
|
|
r = await app.inject({ method: 'GET', url: '/api/zones' });
|
|
assert.equal(r.statusCode, 200);
|
|
assert.equal(r.json().count, 1);
|
|
assert.equal(r.json().zones[0].ZoneName, 'DL');
|
|
assert.equal(r.json().zones[0].Spaces[0].SpaceId, 1);
|
|
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();
|
|
});
|