diff --git a/.env.example b/.env.example index a58de17..0c8dfc0 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ # le callback OIDC, l'URL d'image envoyée à l'écran, et les liens du README. APP_DOMAIN=trmnl.loxi.ch # Port publié en local (dev). En production, Traefik s'en charge : aucun port publié. -APP_PORT=3000 +APP_PORT=3010 APP_BIND=127.0.0.1 TZ=Europe/Zurich @@ -21,6 +21,9 @@ POSTGRES_DB=horaires POSTGRES_USER=horaires POSTGRES_PASSWORD= DATABASE_URL=postgresql://horaires:CHANGEME@db:5432/horaires?schema=public +# Only needed to run the integration tests. They truncate every table, so this +# must never point at a database holding anything you want to keep. +TEST_DATABASE_URL= # ----------------------------------------------------------------------------- # Authentification — Authentik (OIDC, Authorization Code + PKCE) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7e836d..68dfdb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,28 @@ jobs: quality: name: Lint, typecheck, tests runs-on: ubuntu-latest + + # The integration tests exercise the device API against a real database. + # Mocking Prisma here would only prove the mock works. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: horaires_test + POSTGRES_USER: horaires + POSTGRES_PASSWORD: horaires + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U horaires -d horaires_test" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + env: + DATABASE_URL: postgresql://horaires:horaires@localhost:5432/horaires_test?schema=public + TEST_DATABASE_URL: postgresql://horaires:horaires@localhost:5432/horaires_test?schema=public + steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -22,6 +44,7 @@ jobs: cache: npm - run: npm ci - run: npx prisma generate + - run: npx prisma migrate deploy - run: npm run lint - run: npm run typecheck - run: npm run coverage diff --git a/app/api/device/image/[hash]/route.ts b/app/api/device/image/[hash]/route.ts new file mode 100644 index 0000000..334bd22 --- /dev/null +++ b/app/api/device/image/[hash]/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { findStoredImage } from '@/lib/screen/service'; + +export const dynamic = 'force-dynamic'; + +const CONTENT_TYPES: Record = { + bmp: 'image/bmp', + png: 'image/png', +}; + +/** + * Serves a rendered panel image. + * + * The path is the hash of the bytes, so the content can never change under a + * given URL: it is safe to cache forever, and it cannot be enumerated. No + * authentication — the firmware fetches it as a plain image, and an + * unguessable immutable URL is the protection. + */ +export async function GET(_request: Request, { params }: { params: Promise<{ hash: string }> }) { + const { hash: raw } = await params; + const [hash, extension] = raw.split('.'); + + if (!hash || !/^[0-9a-f]{8,64}$/.test(hash)) { + return new NextResponse(null, { status: 404 }); + } + + const image = await findStoredImage(hash); + if (!image) { + return new NextResponse(null, { status: 404 }); + } + + const contentType = CONTENT_TYPES[extension ?? image.format] ?? 'application/octet-stream'; + + return new NextResponse(new Uint8Array(image.bytes), { + headers: { + 'Content-Type': contentType, + 'Content-Length': String(image.bytes.length), + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/app/api/display/route.ts b/app/api/display/route.ts new file mode 100644 index 0000000..4ba2a64 --- /dev/null +++ b/app/api/display/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; + +import { publicBaseUrl } from '@/lib/config'; +import { clientIp, deviceHeader, deviceNumber } from '@/lib/device/headers'; +import { computeRefreshRate } from '@/lib/device/refresh'; +import { authenticateDevice } from '@/lib/device/session'; +import { prisma } from '@/lib/db'; +import { rateLimit } from '@/lib/ratelimit'; +import { buildCurrentScreen, renderAndStore } from '@/lib/screen/service'; + +export const dynamic = 'force-dynamic'; + +/** + * The only call that matters. The panel wakes, asks what to show, and goes + * back to sleep for `refresh_rate` seconds. + * + * `filename` is the hash of the image bytes: when it matches what the firmware + * already has, it skips the redraw. That is where the battery life comes from, + * so the renderer must stay byte-stable for unchanged content. + */ +export async function GET(request: Request) { + const limit = rateLimit(`display:${clientIp(request)}`, 60, 60_000); + if (!limit.allowed) { + return NextResponse.json( + { error: 'Trop de requêtes' }, + { status: 429, headers: { 'Retry-After': String(limit.retryAfter) } }, + ); + } + + const device = await authenticateDevice(request); + if (!device) { + return NextResponse.json({ error: 'Jeton invalide' }, { status: 401 }); + } + + const now = new Date(); + const baseUrl = publicBaseUrl(request); + const { payload, settings, status } = await buildCurrentScreen(now, baseUrl); + const image = await renderAndStore(payload, settings.imageFormat); + + const refreshRate = computeRefreshRate({ + now, + status, + timezone: settings.timezone, + openSec: settings.refreshRateOpenSec, + closedSec: settings.refreshRateClosedSec, + }); + + await prisma.device.update({ + where: { id: device.id }, + data: { + lastSeenAt: now, + fwVersion: deviceHeader(request, 'fw-version'), + batteryVoltage: deviceNumber(request, 'battery-voltage'), + percentCharged: roundOrNull(deviceNumber(request, 'percent-charged')), + rssi: roundOrNull(deviceNumber(request, 'rssi')), + lastFilename: image.filename, + lastRefreshRate: refreshRate, + }, + }); + + return NextResponse.json( + { + image_url: `${baseUrl}/api/device/image/${image.filename}`, + filename: image.filename, + refresh_rate: refreshRate, + // Firmware updates are not this application's business: it drives a + // display, it does not manage the fleet. + update_firmware: false, + reset_firmware: false, + firmware_url: null, + firmware_version: null, + special_function: 'none', + image_url_timeout: 0, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} + +function roundOrNull(value: number | null): number | null { + return value === null ? null : Math.round(value); +} diff --git a/app/api/log/route.ts b/app/api/log/route.ts new file mode 100644 index 0000000..9348379 --- /dev/null +++ b/app/api/log/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; + +import { authenticateDevice } from '@/lib/device/session'; +import { clientIp } from '@/lib/device/headers'; +import { prisma } from '@/lib/db'; +import { rateLimit } from '@/lib/ratelimit'; + +export const dynamic = 'force-dynamic'; + +/** Never let a firmware in a retry loop fill the table in one request. */ +const MAX_ENTRIES_PER_CALL = 50; + +type IncomingLog = { + message?: unknown; + level?: unknown; + created_at?: unknown; +}; + +/** + * Firmware-side logs. Answered with 204 whatever happens to the contents: a + * device that cannot file a log must not conclude the server is down and + * start retrying, and these records are diagnostics, not data. + */ +export async function POST(request: Request) { + const limit = rateLimit(`log:${clientIp(request)}`, 30, 60_000); + if (!limit.allowed) { + return new NextResponse(null, { status: 429 }); + } + + const device = await authenticateDevice(request); + if (!device) { + return NextResponse.json({ error: 'Jeton invalide' }, { status: 401 }); + } + + let entries: IncomingLog[] = []; + try { + const body: unknown = await request.json(); + const logs = (body as { logs?: unknown } | null)?.logs; + if (Array.isArray(logs)) { + entries = logs.slice(0, MAX_ENTRIES_PER_CALL) as IncomingLog[]; + } + } catch { + // A malformed body is a diagnostic in itself; 204 keeps the device calm. + return new NextResponse(null, { status: 204 }); + } + + if (entries.length > 0) { + await prisma.deviceLog.createMany({ + data: entries.map((entry) => ({ + deviceId: device.id, + level: levelOf(entry.level), + message: String(entry.message ?? '').slice(0, 2000) || '(vide)', + payload: entry as object, + })), + }); + } + + return new NextResponse(null, { status: 204 }); +} + +function levelOf(value: unknown): 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' { + const level = String(value ?? '').toUpperCase(); + return level === 'DEBUG' || level === 'WARN' || level === 'ERROR' ? level : 'INFO'; +} diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts new file mode 100644 index 0000000..d5f25ea --- /dev/null +++ b/app/api/setup/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; + +import { publicBaseUrl } from '@/lib/config'; +import { generateDeviceToken, generateFriendlyId, hashToken, normaliseMac } from '@/lib/device/auth'; +import { clientIp, deviceHeader } from '@/lib/device/headers'; +import { prisma } from '@/lib/db'; +import { rateLimit } from '@/lib/ratelimit'; +import { buildCurrentScreen, renderAndStore } from '@/lib/screen/service'; + +export const dynamic = 'force-dynamic'; + +/** + * First contact. The firmware sends its MAC in the `ID` header and expects a + * token back, which it then stores and presents on every later call. + * + * A device that is already registered is answered with an empty `api_key`: we + * only ever stored the digest, so the original cannot be handed out again. If + * a panel ever loses its token, an administrator re-pairs it from the settings + * page — which is the correct outcome, not a gap. + */ +export async function GET(request: Request) { + const limit = rateLimit(`setup:${clientIp(request)}`, 10, 60_000); + if (!limit.allowed) { + return NextResponse.json( + { status: 429, message: 'Trop de tentatives' }, + { status: 429, headers: { 'Retry-After': String(limit.retryAfter) } }, + ); + } + + const mac = normaliseMac(deviceHeader(request, 'id')); + if (!mac) { + return NextResponse.json( + { status: 404, message: 'Adresse MAC absente ou invalide' }, + { status: 200 }, + ); + } + + const baseUrl = publicBaseUrl(request); + const existing = await prisma.device.findUnique({ where: { macAddress: mac } }); + + if (existing) { + return NextResponse.json({ + status: 200, + api_key: '', + friendly_id: existing.friendlyId, + image_url: await welcomeImageUrl(baseUrl), + message: 'Appareil déjà appairé', + }); + } + + const token = generateDeviceToken(); + const device = await prisma.device.create({ + data: { + macAddress: mac, + friendlyId: await uniqueFriendlyId(), + apiKeyHash: hashToken(token), + }, + }); + + return NextResponse.json({ + status: 200, + api_key: token, + friendly_id: device.friendlyId, + image_url: await welcomeImageUrl(baseUrl), + message: 'Bienvenue', + }); +} + +/** + * Never let a rendering failure cost us the pairing. + * + * The token is issued once and only its digest is kept, so if the response + * that carries it fails the device is stranded: registered, but holding no + * credential, and unable to register again. The welcome image is worth far + * less than that, and the next /api/display call will produce one anyway. + */ +async function welcomeImageUrl(baseUrl: string): Promise { + try { + const { payload, settings } = await buildCurrentScreen(new Date(), baseUrl); + const image = await renderAndStore(payload, settings.imageFormat); + return `${baseUrl}/api/device/image/${image.filename}`; + } catch (error) { + console.error('Could not render the welcome image', error); + return ''; + } +} + +async function uniqueFriendlyId(): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const candidate = generateFriendlyId(); + const taken = await prisma.device.findUnique({ where: { friendlyId: candidate } }); + if (!taken) { + return candidate; + } + } + throw new Error('Could not allocate a friendly id'); +} diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..4712c76 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,45 @@ +/** + * Environment access, in one place so nothing else has to guess. + */ + +/** + * The origin the device and the browser reach us on. + * + * Prefers what the request actually arrived as, because the panel has to be + * handed a URL it can fetch — a mismatch here shows up as a blank screen in a + * shop window, which is the most expensive place to debug. Falls back to the + * configured domain for calls that have no request, such as background jobs. + */ +export function publicBaseUrl(request?: Request): string { + if (request) { + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host'); + if (host) { + const proto = request.headers.get('x-forwarded-proto') ?? new URL(request.url).protocol.replace(':', ''); + return `${proto}://${host}`; + } + } + + const configured = process.env.NEXTAUTH_URL; + if (configured) { + return configured.replace(/\/$/, ''); + } + + const domain = process.env.APP_DOMAIN; + if (domain) { + return domain.startsWith('http') ? domain.replace(/\/$/, '') : `https://${domain}`; + } + + return 'http://localhost:3000'; +} + +/** + * Whether the panel may talk to us over plain HTTP. + * + * Off by default. It exists because these ESP32 firmwares sometimes fail on a + * certificate chain, and a shop with a blank window needs a way out that does + * not involve reflashing. The device token is separate and revocable precisely + * so this switch stays survivable. + */ +export function deviceAllowsHttp(): boolean { + return process.env.DEVICE_ALLOW_HTTP === 'true'; +} diff --git a/lib/device/auth.test.ts b/lib/device/auth.test.ts new file mode 100644 index 0000000..391fe15 --- /dev/null +++ b/lib/device/auth.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + generateDeviceToken, + generateFriendlyId, + hashToken, + normaliseMac, + tokenMatches, +} from './auth'; + +describe('generateDeviceToken', () => { + it('is URL-safe and long enough to be worth nothing to a guesser', () => { + const token = generateDeviceToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token.length).toBeGreaterThanOrEqual(43); + }); + + it('never repeats', () => { + const tokens = new Set(Array.from({ length: 50 }, generateDeviceToken)); + expect(tokens.size).toBe(50); + }); +}); + +describe('hashToken / tokenMatches', () => { + it('accepts the right token', () => { + const token = generateDeviceToken(); + expect(tokenMatches(token, hashToken(token))).toBe(true); + }); + + it('rejects the wrong token', () => { + expect(tokenMatches('wrong', hashToken(generateDeviceToken()))).toBe(false); + }); + + it('rejects an empty token', () => { + expect(tokenMatches('', hashToken('something'))).toBe(false); + }); + + it('rejects a stored digest of the wrong length without throwing', () => { + // A truncated or corrupted column must fail closed, not crash the route. + expect(tokenMatches('token', 'abcd')).toBe(false); + }); + + it('rejects a stored digest that is not hex without throwing', () => { + expect(tokenMatches('token', 'not-hex-at-all')).toBe(false); + }); + + it('produces a 64-character hex digest', () => { + expect(hashToken('token')).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe('generateFriendlyId', () => { + it('uses only characters that survive being read aloud', () => { + for (let attempt = 0; attempt < 50; attempt += 1) { + expect(generateFriendlyId()).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/); + } + }); + + it('honours a requested length', () => { + expect(generateFriendlyId(10)).toHaveLength(10); + }); +}); + +describe('normaliseMac', () => { + it('accepts the colon form', () => { + expect(normaliseMac('FE:68:44:CE:CA:C3')).toBe('FE:68:44:CE:CA:C3'); + }); + + it('accepts lower case, dashes and bare hex', () => { + // A device must not be able to register twice by spelling itself + // differently; the address is a primary key here. + expect(normaliseMac('fe:68:44:ce:ca:c3')).toBe('FE:68:44:CE:CA:C3'); + expect(normaliseMac('fe-68-44-ce-ca-c3')).toBe('FE:68:44:CE:CA:C3'); + expect(normaliseMac('fe6844ceCAc3')).toBe('FE:68:44:CE:CA:C3'); + }); + + it('rejects anything that is not twelve hex digits', () => { + expect(normaliseMac('FE:68:44:CE:CA')).toBeNull(); + expect(normaliseMac('not a mac')).toBeNull(); + expect(normaliseMac('')).toBeNull(); + expect(normaliseMac(null)).toBeNull(); + expect(normaliseMac(undefined)).toBeNull(); + }); +}); diff --git a/lib/device/auth.ts b/lib/device/auth.ts new file mode 100644 index 0000000..aa4e0b3 --- /dev/null +++ b/lib/device/auth.ts @@ -0,0 +1,66 @@ +/** + * Device credentials. + * + * The panel cannot sign in through the identity provider, so it carries a + * static bearer token instead. That makes two things non-negotiable: only the + * digest is ever stored, and comparisons run in constant time — an endpoint + * that leaks timing is an endpoint that leaks the token. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** Unambiguous in print: no O/0, no I/1. Friendly ids get read aloud. */ +const FRIENDLY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** 256 bits of entropy, URL-safe so it survives a captive-portal form. */ +export function generateDeviceToken(): string { + return randomBytes(32).toString('base64url'); +} + +export function hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +/** + * Constant-time comparison of a presented token against a stored digest. + * + * Both sides are hashed first, so the buffers always have the same length and + * `timingSafeEqual` can never throw on a length mismatch — which would itself + * be an observable signal. + */ +export function tokenMatches(presented: string, storedHash: string): boolean { + const presentedDigest = Buffer.from(hashToken(presented), 'hex'); + let storedDigest: Buffer; + try { + storedDigest = Buffer.from(storedHash, 'hex'); + } catch { + return false; + } + if (storedDigest.length !== presentedDigest.length) { + return false; + } + return timingSafeEqual(presentedDigest, storedDigest); +} + +export function generateFriendlyId(length = 6): string { + const bytes = randomBytes(length); + return Array.from(bytes, (byte) => FRIENDLY_ALPHABET[byte % FRIENDLY_ALPHABET.length]).join(''); +} + +/** + * Normalises a MAC address to upper-case colon-separated form. + * + * Firmwares are not consistent about separators or case, and the address is a + * primary key here, so a device must not be able to register twice by + * capitalising itself differently. + */ +export function normaliseMac(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const hex = value.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (hex.length !== 12) { + return null; + } + return (hex.match(/.{2}/g) ?? []).join(':'); +} diff --git a/lib/device/headers.ts b/lib/device/headers.ts new file mode 100644 index 0000000..e825826 --- /dev/null +++ b/lib/device/headers.ts @@ -0,0 +1,33 @@ +/** + * Header reading for the device API. + * + * The firmwares are not consistent: the TRMNL documentation shows + * `ACCESS_TOKEN` and `BATTERY_VOLTAGE`, the Seeed sources show `Access-Token` + * and `Battery-Voltage`. HTTP header names are case-insensitive but underscores + * and hyphens are different names, so every field is read under both spellings. + * Being liberal here costs nothing; being wrong costs a blank shop window. + */ + +export function deviceHeader(request: Request, name: string): string | null { + const hyphen = name.replace(/_/g, '-'); + const underscore = name.replace(/-/g, '_'); + return request.headers.get(hyphen) ?? request.headers.get(underscore); +} + +export function deviceNumber(request: Request, name: string): number | null { + const raw = deviceHeader(request, name); + if (raw === null || raw.trim() === '') { + return null; + } + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +/** The caller's address, as seen through whatever proxy is in front of us. */ +export function clientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + return forwarded.split(',')[0]?.trim() ?? 'unknown'; + } + return request.headers.get('x-real-ip') ?? 'unknown'; +} diff --git a/lib/device/refresh.test.ts b/lib/device/refresh.test.ts new file mode 100644 index 0000000..9f86e3e --- /dev/null +++ b/lib/device/refresh.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResolvedDay, ShopStatus, ShopStatusKind } from '@/lib/schedule/types'; + +import { + CHANGE_MARGIN_SEC, + MAX_REFRESH_SEC, + MIN_REFRESH_SEC, + computeRefreshRate, +} from './refresh'; + +const ZURICH = 'Europe/Zurich'; +const OPEN_SEC = 600; +const CLOSED_SEC = 7200; + +const DAY: ResolvedDay = { + date: '2026-09-22', + dayOfWeek: 2, + isOpen: true, + slots: [], + isException: false, + exceptionKind: null, + noteFr: null, + noteEn: null, +}; + +function status( + kind: ShopStatusKind, + nextChangeAt: ShopStatus['nextChangeAt'] = null, +): ShopStatus { + return { status: kind, today: DAY, nextChangeAt, nextOpening: nextChangeAt }; +} + +function rate(now: string, value: ShopStatus): number { + return computeRefreshRate({ + now: new Date(now), + status: value, + timezone: ZURICH, + openSec: OPEN_SEC, + closedSec: CLOSED_SEC, + }); +} + +describe('computeRefreshRate', () => { + it('uses the short interval while the shop is trading', () => { + // 12:00 local, closing at 18:30: far from any change, so the base wins. + expect(rate('2026-09-22T10:00:00Z', status('OPEN', { date: '2026-09-22', time: '18:30' }))).toBe( + OPEN_SEC, + ); + }); + + it('uses the long interval overnight', () => { + expect( + rate('2026-09-22T21:00:00Z', status('CLOSED', { date: '2026-09-23', time: '10:00' })), + ).toBe(CLOSED_SEC); + }); + + it('treats opening soon as a trading moment', () => { + // Someone is standing at the window right now; the screen must not be stale. + expect( + rate('2026-09-22T07:40:00Z', status('OPENING_SOON', { date: '2026-09-22', time: '10:00' })), + ).toBeLessThanOrEqual(OPEN_SEC); + }); + + it('never sleeps through a change of state', () => { + // 17:00 local, closing at 18:30 is 90 minutes away, well inside the long + // interval. The device must still wake just after the change. + const seconds = rate( + '2026-09-22T15:00:00Z', + status('CLOSED', { date: '2026-09-22', time: '18:30' }), + ); + expect(seconds).toBe(Math.min(CLOSED_SEC, 90 * 60 + CHANGE_MARGIN_SEC)); + }); + + it('wakes just after the change rather than exactly on it', () => { + // Closing in ten minutes: waking at the stroke of 18:30 risks redrawing + // the old state, so add the margin. + const seconds = rate( + '2026-09-22T16:20:00Z', + status('OPEN', { date: '2026-09-22', time: '18:30' }), + ); + expect(seconds).toBe(130 * 60 > OPEN_SEC ? OPEN_SEC : 130 * 60 + CHANGE_MARGIN_SEC); + }); + + it('shortens the long interval for a change that falls inside it', () => { + // Closed, reopening in 30 minutes, base interval 2 hours. + const seconds = rate( + '2026-09-22T07:30:00Z', + status('CLOSED', { date: '2026-09-22', time: '10:00' }), + ); + expect(seconds).toBe(30 * 60 + CHANGE_MARGIN_SEC); + }); + + it('falls back to the base interval when nothing is scheduled ahead', () => { + expect(rate('2026-09-22T10:00:00Z', status('CLOSED', null))).toBe(CLOSED_SEC); + }); + + it('ignores a change that has already passed', () => { + // A stale next-change must not produce a negative or zero interval. + expect( + rate('2026-09-22T16:00:00Z', status('CLOSED', { date: '2026-09-22', time: '10:00' })), + ).toBe(CLOSED_SEC); + }); + + it('never returns less than the floor', () => { + const seconds = rate( + '2026-09-22T10:00:00Z', + status('OPEN', { date: '2026-09-22', time: '12:00' }), + ); + expect(seconds).toBeGreaterThanOrEqual(MIN_REFRESH_SEC); + }); + + it('never returns more than the ceiling', () => { + expect( + computeRefreshRate({ + now: new Date('2026-09-22T10:00:00Z'), + status: status('CLOSED', null), + timezone: ZURICH, + openSec: OPEN_SEC, + closedSec: 999_999, + }), + ).toBe(MAX_REFRESH_SEC); + }); + + it('handles a change several days out without overflowing', () => { + expect( + rate('2026-09-26T17:00:00Z', status('CLOSED', { date: '2026-09-29', time: '10:00' })), + ).toBe(CLOSED_SEC); + }); +}); diff --git a/lib/device/refresh.ts b/lib/device/refresh.ts new file mode 100644 index 0000000..526d46d --- /dev/null +++ b/lib/device/refresh.ts @@ -0,0 +1,76 @@ +/** + * How long to let the panel sleep. + * + * In BYOS there is no way to push: the device sleeps, wakes, asks, and sleeps + * again. The only lever is how long it sleeps for, so this is the entire + * freshness-versus-battery trade-off of the product, in one function. + * + * Two rules: + * - sleep briefly while the shop is trading, and at length overnight; + * - never sleep through a change of state. If the door opens in twenty + * minutes, wake in twenty-one, whatever the base interval says. + */ + +import { civilDaysBetween, minutesOfTime, toCivilDate, toCivilTime } from '@/lib/schedule/civil'; +import type { ShopStatus } from '@/lib/schedule/types'; + +/** Below this the panel would spend its life awake. */ +export const MIN_REFRESH_SEC = 60; + +/** A panel that has not checked in for six hours is indistinguishable from a dead one. */ +export const MAX_REFRESH_SEC = 21_600; + +/** Wake just after the change, not exactly on it. */ +export const CHANGE_MARGIN_SEC = 60; + +export type RefreshInput = { + now: Date; + status: ShopStatus; + timezone: string; + openSec: number; + closedSec: number; +}; + +export function computeRefreshRate({ + now, + status, + timezone, + openSec, + closedSec, +}: RefreshInput): number { + // "Opening soon" gets the short interval too: that is the moment the screen + // is about to be wrong, and the moment someone is standing at the window. + const busy = + status.status === 'OPEN' || status.status === 'CLOSING_SOON' || status.status === 'OPENING_SOON'; + + let seconds = busy ? openSec : closedSec; + + if (status.nextChangeAt) { + const untilChange = secondsUntil(now, status.nextChangeAt, timezone); + if (untilChange > 0) { + seconds = Math.min(seconds, untilChange + CHANGE_MARGIN_SEC); + } + } + + return clamp(Math.round(seconds)); +} + +function clamp(seconds: number): number { + return Math.min(MAX_REFRESH_SEC, Math.max(MIN_REFRESH_SEC, seconds)); +} + +/** + * Seconds from now until a wall-clock moment. + * + * Measured on the wall clock, which is off by an hour on the two nights a year + * the clocks change. That only shifts one wake-up, and the alternative — a + * real timezone conversion back to an instant — buys nothing the panel can + * perceive. + */ +function secondsUntil(now: Date, target: { date: string; time: string }, timezone: string): number { + const today = toCivilDate(now, timezone); + const nowMinutes = minutesOfTime(toCivilTime(now, timezone)); + const days = civilDaysBetween(today, target.date); + const minutes = days * 1440 + minutesOfTime(target.time) - nowMinutes; + return minutes * 60 - now.getUTCSeconds(); +} diff --git a/lib/device/session.ts b/lib/device/session.ts new file mode 100644 index 0000000..f7df761 --- /dev/null +++ b/lib/device/session.ts @@ -0,0 +1,45 @@ +/** + * Resolving which device is calling. + * + * The panel cannot sign in through the identity provider, so these routes are + * the only ones outside OIDC. They are kept narrow on purpose: a bearer token + * compared in constant time, and nothing else. + */ + +import { prisma } from '@/lib/db'; + +import { hashToken, normaliseMac, tokenMatches } from './auth'; +import { deviceHeader } from './headers'; + +export type DeviceRow = Awaited>; + +export async function authenticateDevice(request: Request): Promise | null> { + const token = deviceHeader(request, 'access-token') ?? bearer(request); + if (!token) { + return null; + } + + // When the firmware sends its MAC, look the device up by it and compare the + // token in constant time. That is the path the spec asks for. + const mac = normaliseMac(deviceHeader(request, 'id')); + if (mac) { + const device = await prisma.device.findUnique({ where: { macAddress: mac } }); + if (device && device.isActive && tokenMatches(token, device.apiKeyHash)) { + return device; + } + // Fall through: some firmware revisions omit ID on /api/log. + } + + // Looking the row up by the digest reveals nothing the digest does not + // already contain, and the database index does the work. + const device = await prisma.device.findFirst({ where: { apiKeyHash: hashToken(token) } }); + return device && device.isActive ? device : null; +} + +function bearer(request: Request): string | null { + const header = request.headers.get('authorization'); + if (!header?.toLowerCase().startsWith('bearer ')) { + return null; + } + return header.slice(7).trim() || null; +} diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 0000000..d597b06 --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,49 @@ +/** + * A small in-memory sliding-window limiter. + * + * Deliberately per-process and not backed by Redis: this application runs as a + * single container serving one shop and a handful of devices. The job here is + * to stop a firmware stuck in a retry loop from hammering the renderer, not to + * survive a distributed attack. + */ + +const windows = new Map(); + +export type RateLimitResult = { + allowed: boolean; + /** Seconds until the caller may try again; 0 when allowed. */ + retryAfter: number; +}; + +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const cutoff = now - windowMs; + + const hits = (windows.get(key) ?? []).filter((timestamp) => timestamp > cutoff); + + if (hits.length >= limit) { + windows.set(key, hits); + const oldest = hits[0] ?? now; + return { allowed: false, retryAfter: Math.ceil((oldest + windowMs - now) / 1000) }; + } + + hits.push(now); + windows.set(key, hits); + + // Opportunistic sweep: without it a long-running process would hold a key + // for every IP that ever called, forever. + if (windows.size > 1000) { + for (const [existing, timestamps] of windows) { + if (timestamps.every((timestamp) => timestamp <= cutoff)) { + windows.delete(existing); + } + } + } + + return { allowed: true, retryAfter: 0 }; +} + +/** Test seam: forget every window. */ +export function resetRateLimits(): void { + windows.clear(); +} diff --git a/lib/schedule/civil.ts b/lib/schedule/civil.ts index 159eb1f..a3cf38f 100644 --- a/lib/schedule/civil.ts +++ b/lib/schedule/civil.ts @@ -91,6 +91,11 @@ export function civilDayOfWeek(date: CivilDate): number { return new Date(civilToUtcMs(date)).getUTCDay(); } +/** Whole days from `a` to `b`; negative when `b` is earlier. */ +export function civilDaysBetween(a: CivilDate, b: CivilDate): number { + return Math.round((civilToUtcMs(b) - civilToUtcMs(a)) / MS_PER_DAY); +} + /** Chronological comparator; civil dates are lexicographically ordered too. */ export function compareCivil(a: CivilDate, b: CivilDate): number { return a < b ? -1 : a > b ? 1 : 0; diff --git a/lib/schedule/context.ts b/lib/schedule/context.ts new file mode 100644 index 0000000..2c1034d --- /dev/null +++ b/lib/schedule/context.ts @@ -0,0 +1,149 @@ +/** + * The only place that turns database rows into a ScheduleContext. + * + * Keeping every query here is what lets `resolver.ts` stay pure. Nothing below + * makes a scheduling decision; it loads, narrows and converts. + */ + +import { prisma } from '@/lib/db'; + +import { addCivilDays, startOfCivilWeek, toCivilDate, type CivilDate } from './civil'; +import type { ScheduleContext, Slot } from './types'; + +/** + * How far either side of today to load. + * + * Back to the Monday of the current week, because the screen draws it; forward + * past the resolver's fourteen-day search, so a reopening is never missed for + * want of a row. + */ +const LOOK_BACK_DAYS = 7; +const LOOK_AHEAD_DAYS = 21; + +export type LoadedSettings = { + shopName: string; + timezone: string; + countryIsoCode: string; + subdivisionCode: string; + refreshRateOpenSec: number; + refreshRateClosedSec: number; + imageFormat: 'bmp' | 'png'; +}; + +const FALLBACK_SETTINGS: LoadedSettings = { + shopName: 'ITA ITO', + timezone: 'Europe/Zurich', + countryIsoCode: 'CH', + subdivisionCode: 'CH-GE', + refreshRateOpenSec: 600, + refreshRateClosedSec: 7200, + imageFormat: 'bmp', +}; + +export async function loadSettings(): Promise { + const row = await prisma.settings.findUnique({ where: { id: 'singleton' } }); + if (!row) { + // A freshly created database must still serve a screen rather than a 500. + return FALLBACK_SETTINGS; + } + return { + shopName: row.shopName, + timezone: row.timezone, + countryIsoCode: row.countryIsoCode, + subdivisionCode: row.subdivisionCode, + refreshRateOpenSec: row.refreshRateOpenSec, + refreshRateClosedSec: row.refreshRateClosedSec, + imageFormat: row.imageFormat === 'png' ? 'png' : 'bmp', + }; +} + +export async function loadScheduleContext( + now: Date, + settings: LoadedSettings, +): Promise { + const today = toCivilDate(now, settings.timezone); + // The window starts at the Monday of the current week or earlier, whichever + // reaches further back, so the week strip is always fully covered. + const from = min(startOfCivilWeek(today), addCivilDays(today, -LOOK_BACK_DAYS)); + const to = addCivilDays(today, LOOK_AHEAD_DAYS); + + const [weekly, exceptions, vacations, holidays] = await Promise.all([ + prisma.weeklySchedule.findMany({ orderBy: { dayOfWeek: 'asc' } }), + prisma.scheduleException.findMany({ + where: { date: { gte: asDate(from), lte: asDate(to) } }, + }), + prisma.vacationPeriod.findMany({ + where: { startDate: { lte: asDate(to) }, endDate: { gte: asDate(from) } }, + }), + prisma.publicHoliday.findMany({ + where: { + date: { gte: asDate(from), lte: asDate(to) }, + subdivisionCode: settings.subdivisionCode, + }, + }), + ]); + + return { + timezone: settings.timezone, + weekly: weekly.map((day) => ({ + dayOfWeek: day.dayOfWeek, + isClosed: day.isClosed, + slots: asSlots(day.slots), + })), + exceptions: exceptions.map((exception) => ({ + date: asCivil(exception.date), + isClosed: exception.isClosed, + slots: exception.slots === null ? null : asSlots(exception.slots), + reason: exception.reason, + noteFr: exception.noteFr, + noteEn: exception.noteEn, + source: exception.source, + })), + vacations: vacations.map((period) => ({ + startDate: asCivil(period.startDate), + endDate: asCivil(period.endDate), + labelFr: period.labelFr, + labelEn: period.labelEn, + })), + holidays: holidays.map((holiday) => ({ + date: asCivil(holiday.date), + nameFr: holiday.nameFr, + nameEn: holiday.nameEn, + isAutoClosed: holiday.isAutoClosed, + })), + }; +} + +/** + * A `@db.Date` column comes back as midnight UTC, so the first ten characters + * of the ISO string are the civil date with no timezone maths involved. + */ +function asCivil(value: Date): CivilDate { + return value.toISOString().slice(0, 10); +} + +function asDate(value: CivilDate): Date { + return new Date(`${value}T00:00:00.000Z`); +} + +function min(a: CivilDate, b: CivilDate): CivilDate { + return a < b ? a : b; +} + +/** + * Slots live in a JSON column, so they arrive untyped. Anything malformed is + * dropped rather than allowed to reach the resolver: a bad row should cost one + * day's hours, not the whole screen. + */ +function asSlots(value: unknown): Slot[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter( + (slot): slot is Slot => + typeof slot === 'object' && + slot !== null && + typeof (slot as Slot).open === 'string' && + typeof (slot as Slot).close === 'string', + ); +} diff --git a/lib/screen/service.ts b/lib/screen/service.ts new file mode 100644 index 0000000..e2c7583 --- /dev/null +++ b/lib/screen/service.ts @@ -0,0 +1,121 @@ +/** + * Assembles, renders and stores the current panel image. + * + * The image is addressed by the hash of its own bytes. That single decision + * buys three things: the device skips the redraw when the name has not changed + * (which is where the battery life comes from), the URL is safe to cache + * forever, and it cannot be enumerated. + */ + +import { createHash } from 'node:crypto'; + +import { prisma } from '@/lib/db'; +import { loadScheduleContext, loadSettings, type LoadedSettings } from '@/lib/schedule/context'; +import { getCurrentStatus } from '@/lib/schedule/resolver'; +import type { ScheduleContext, ShopStatus } from '@/lib/schedule/types'; + +import type { ScreenPayload } from './contract'; +import { encodeScreen } from './encode'; +import { renderScreenSvg } from './render'; +import { buildScreenPayload, type MessageCandidate } from './viewmodel'; + +/** Long enough that a collision is not a thing worth thinking about. */ +const HASH_LENGTH = 16; + +/** How many rendered images to keep. Enough to cover a device mid-fetch. */ +const KEEP_IMAGES = 20; + +export type CurrentScreen = { + payload: ScreenPayload; + settings: LoadedSettings; + context: ScheduleContext; + status: ShopStatus; +}; + +export async function buildCurrentScreen(now: Date, baseUrl: string): Promise { + const settings = await loadSettings(); + const context = await loadScheduleContext(now, settings); + const messages = await loadMessages(); + + return { + payload: buildScreenPayload({ + now, + ctx: context, + shopName: settings.shopName, + logoUrl: `${baseUrl}/brand/logo-eink.png`, + messages, + }), + settings, + context, + status: getCurrentStatus(now, context), + }; +} + +export type StoredImage = { + hash: string; + format: 'bmp' | 'png'; + filename: string; +}; + +/** + * Renders the payload and stores the bytes under their own hash. + * + * Re-rendering an unchanged screen produces the same hash and the same row, so + * this is idempotent by construction: nothing accumulates while the shop's + * hours stay put. + */ +export async function renderAndStore( + payload: ScreenPayload, + format: 'bmp' | 'png', +): Promise { + const svg = await renderScreenSvg(payload); + const bytes = encodeScreen(svg, format); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH); + const filename = `${hash}.${format}`; + + await prisma.screenImage.upsert({ + where: { hash }, + update: {}, + // Prisma's Bytes maps to Uint8Array; Buffer is one, but its backing store + // is typed loosely enough that TypeScript wants it said explicitly. + create: { hash, format, bytes: new Uint8Array(bytes) }, + }); + + await pruneOldImages(); + + return { hash, format, filename }; +} + +export async function findStoredImage(hash: string): Promise<{ bytes: Buffer; format: string } | null> { + const row = await prisma.screenImage.findUnique({ where: { hash } }); + if (!row) { + return null; + } + return { bytes: Buffer.from(row.bytes), format: row.format }; +} + +async function pruneOldImages(): Promise { + const keep = await prisma.screenImage.findMany({ + orderBy: { createdAt: 'desc' }, + take: KEEP_IMAGES, + select: { hash: true }, + }); + await prisma.screenImage.deleteMany({ + where: { hash: { notIn: keep.map((row) => row.hash) } }, + }); +} + +async function loadMessages(): Promise { + const rows = await prisma.message.findMany({ + where: { isActive: true }, + orderBy: { priority: 'desc' }, + }); + return rows.map((row) => ({ + textFr: row.textFr, + textEn: row.textEn, + startsAt: row.startsAt, + endsAt: row.endsAt, + priority: row.priority, + isActive: row.isActive, + })); +} diff --git a/next.config.ts b/next.config.ts index 234bde6..845c54c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,8 +5,20 @@ const nextConfig: NextConfig = { // dependencies ship, not the whole node_modules tree. output: 'standalone', reactStrictMode: true, - // node-cron is started from instrumentation.ts; it must not be bundled. - serverExternalPackages: ['node-cron', '@resvg/resvg-js'], + // Next writes its own AGENTS.md/CLAUDE.md otherwise; this project documents + // itself in README.md and PLAN.md. + agentRules: false, + // These must be required from node_modules at runtime, not bundled. + // satori loads harfbuzz and yoga as WebAssembly by relative path; bundling + // rewrites that path and the renderer dies with a missing hb.wasm. resvg is + // a native addon, and node-cron is started from instrumentation.ts. + serverExternalPackages: [ + 'satori', + 'yoga-wasm-web', + 'harfbuzzjs', + '@resvg/resvg-js', + 'node-cron', + ], }; export default nextConfig; diff --git a/package.json b/package.json index e948632..ee07b99 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "node": ">=20.9" }, "scripts": { - "dev": "next dev", + "dev": "next dev --port 3010", "build": "next build", "start": "next start", "lint": "eslint . --max-warnings 0", diff --git a/prisma/migrations/20260920155146_screen_images/migration.sql b/prisma/migrations/20260920155146_screen_images/migration.sql new file mode 100644 index 0000000..1dc3545 --- /dev/null +++ b/prisma/migrations/20260920155146_screen_images/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "screen_images" ( + "hash" TEXT NOT NULL, + "format" TEXT NOT NULL, + "bytes" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "screen_images_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE INDEX "screen_images_createdAt_idx" ON "screen_images"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d81dab..f1b6d05 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -220,6 +220,23 @@ model DeviceLog { @@map("device_logs") } +/// A rendered panel image, addressed by the hash of its own bytes. +/// +/// Stored rather than regenerated on demand because the device fetches the +/// image in a second request, moments after being told its name: a restart or +/// a data change in between would otherwise hand it a 404. Old rows are pruned +/// by the daily job. +model ScreenImage { + /// SHA-256 of `bytes`, truncated; also the filename given to the firmware. + hash String @id + format String + bytes Bytes + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("screen_images") +} + /// Last outcome of each background sync, so the UI can show "last successful /// sync" instead of failing silently. model SyncState { diff --git a/tests/integration/device-api.test.ts b/tests/integration/device-api.test.ts new file mode 100644 index 0000000..a87b521 --- /dev/null +++ b/tests/integration/device-api.test.ts @@ -0,0 +1,275 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { GET as display } from '@/app/api/display/route'; +import { GET as image } from '@/app/api/device/image/[hash]/route'; +import { POST as log } from '@/app/api/log/route'; +import { GET as setup } from '@/app/api/setup/route'; +import { prisma } from '@/lib/db'; +import { resetRateLimits } from '@/lib/ratelimit'; + +import { deviceRequest, hasDatabase, resetDatabase } from './helpers'; + +const MAC = 'FE:68:44:CE:CA:C3'; + +describe.skipIf(!hasDatabase)('device API', () => { + beforeAll(async () => { + await resetDatabase(); + }); + + beforeEach(async () => { + await prisma.deviceLog.deleteMany(); + await prisma.device.deleteMany(); + await prisma.screenImage.deleteMany(); + await prisma.scheduleException.deleteMany(); + resetRateLimits(); + }); + + async function pair(): Promise<{ token: string; friendlyId: string }> { + const response = await setup(deviceRequest('/api/setup', { ID: MAC })); + const body = (await response.json()) as { api_key: string; friendly_id: string }; + return { token: body.api_key, friendlyId: body.friendly_id }; + } + + describe('GET /api/setup', () => { + it('registers an unknown device and hands it a token', async () => { + const response = await setup(deviceRequest('/api/setup', { ID: MAC })); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body.status).toBe(200); + expect(String(body.api_key)).toHaveLength(43); + expect(String(body.friendly_id)).toMatch(/^[A-Z2-9]{6}$/); + + // Only the digest is kept: the plaintext must not be recoverable. + const stored = await prisma.device.findUnique({ where: { macAddress: MAC } }); + expect(stored?.apiKeyHash).toMatch(/^[0-9a-f]{64}$/); + expect(stored?.apiKeyHash).not.toBe(body.api_key); + }); + + it('recognises the same device however the firmware spells its MAC', async () => { + const { friendlyId } = await pair(); + const again = await setup(deviceRequest('/api/setup', { id: 'fe-68-44-ce-ca-c3' })); + const body = (await again.json()) as Record; + + expect(body.friendly_id).toBe(friendlyId); + // The token was issued once and only its digest kept, so it cannot be + // handed out a second time. + expect(body.api_key).toBe(''); + expect(await prisma.device.count()).toBe(1); + }); + + it('refuses a missing or malformed MAC', async () => { + const body = (await (await setup(deviceRequest('/api/setup'))).json()) as { status: number }; + expect(body.status).toBe(404); + expect(await prisma.device.count()).toBe(0); + }); + }); + + describe('GET /api/display', () => { + it('refuses a request with no token', async () => { + await pair(); + expect((await display(deviceRequest('/api/display'))).status).toBe(401); + }); + + it('refuses a request with the wrong token', async () => { + await pair(); + const response = await display( + deviceRequest('/api/display', { 'Access-Token': 'not-the-token', ID: MAC }), + ); + expect(response.status).toBe(401); + }); + + it('refuses a deactivated device', async () => { + const { token } = await pair(); + await prisma.device.update({ where: { macAddress: MAC }, data: { isActive: false } }); + + const response = await display(deviceRequest('/api/display', { 'Access-Token': token })); + expect(response.status).toBe(401); + }); + + it('answers a paired device with an image and a wake interval', async () => { + const { token } = await pair(); + const response = await display( + deviceRequest('/api/display', { 'Access-Token': token, ID: MAC }), + ); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body.filename).toMatch(/^[0-9a-f]{16}\.bmp$/); + expect(body.image_url).toBe(`https://trmnl.example.test/api/device/image/${body.filename}`); + expect(body.refresh_rate).toBeGreaterThan(0); + expect(body.update_firmware).toBe(false); + expect(body.special_function).toBe('none'); + }); + + it('records the telemetry the firmware sends', async () => { + const { token } = await pair(); + await display( + deviceRequest('/api/display', { + 'Access-Token': token, + ID: MAC, + 'FW-Version': '1.5.2', + 'Battery-Voltage': '3.94', + 'Percent-Charged': '82', + RSSI: '-62', + }), + ); + + const device = await prisma.device.findUnique({ where: { macAddress: MAC } }); + expect(device?.fwVersion).toBe('1.5.2'); + expect(device?.batteryVoltage).toBeCloseTo(3.94); + expect(device?.percentCharged).toBe(82); + expect(device?.rssi).toBe(-62); + expect(device?.lastSeenAt).toBeInstanceOf(Date); + }); + + it('accepts the underscore spelling of the token header', async () => { + // The TRMNL docs show ACCESS_TOKEN, the Seeed sources Access-Token. + const { token } = await pair(); + const response = await display(deviceRequest('/api/display', { ACCESS_TOKEN: token })); + expect(response.status).toBe(200); + }); + + it('returns the same filename while nothing changes', async () => { + // This is the battery test: an unchanged filename means the firmware + // skips the redraw entirely. + const { token } = await pair(); + const headers = { 'Access-Token': token, ID: MAC }; + + const first = (await (await display(deviceRequest('/api/display', headers))).json()) as { + filename: string; + }; + const second = (await (await display(deviceRequest('/api/display', headers))).json()) as { + filename: string; + }; + + expect(second.filename).toBe(first.filename); + // And it stored one image, not two. + expect(await prisma.screenImage.count()).toBe(1); + }); + + it('returns a different filename once the hours change', async () => { + const { token } = await pair(); + const headers = { 'Access-Token': token, ID: MAC }; + + const before = (await (await display(deviceRequest('/api/display', headers))).json()) as { + filename: string; + }; + + const today = new Date(); + await prisma.scheduleException.create({ + data: { + date: new Date( + `${today.toISOString().slice(0, 10)}T00:00:00.000Z`, + ), + isClosed: false, + slots: [{ open: '14:00', close: '18:00' }], + reason: 'SPECIAL_EVENT', + noteFr: 'Ouverture exceptionnelle', + source: 'MANUAL', + }, + }); + + const after = (await (await display(deviceRequest('/api/display', headers))).json()) as { + filename: string; + }; + + expect(after.filename).not.toBe(before.filename); + }); + }); + + describe('GET /api/device/image/[hash]', () => { + it('serves the image the device was pointed at', async () => { + const { token } = await pair(); + const { filename } = (await ( + await display(deviceRequest('/api/display', { 'Access-Token': token })) + ).json()) as { filename: string }; + + const response = await image(deviceRequest(`/api/device/image/${filename}`), { + params: Promise.resolve({ hash: filename }), + }); + const bytes = Buffer.from(await response.arrayBuffer()); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/bmp'); + expect(response.headers.get('cache-control')).toContain('immutable'); + + // A real 1-bit 800x480 bitmap, header and all. + expect(bytes.subarray(0, 2).toString('ascii')).toBe('BM'); + expect(bytes.readInt32LE(18)).toBe(800); + expect(bytes.readInt32LE(22)).toBe(480); + expect(bytes.readUInt16LE(28)).toBe(1); + }); + + it('returns 404 for an unknown image', async () => { + const response = await image(deviceRequest('/api/device/image/0000000000000000.bmp'), { + params: Promise.resolve({ hash: '0000000000000000.bmp' }), + }); + expect(response.status).toBe(404); + }); + + it('returns 404 for anything that is not a hash', async () => { + const response = await image(deviceRequest('/api/device/image/x'), { + params: Promise.resolve({ hash: '../../etc/passwd' }), + }); + expect(response.status).toBe(404); + }); + }); + + describe('POST /api/log', () => { + it('refuses a request with no token', async () => { + const response = await log( + deviceRequest('/api/log', {}, { method: 'POST', body: '{"logs":[]}' }), + ); + expect(response.status).toBe(401); + }); + + it('stores what the firmware reports', async () => { + const { token } = await pair(); + const response = await log( + deviceRequest( + '/api/log', + { 'Access-Token': token, 'Content-Type': 'application/json' }, + { + method: 'POST', + body: JSON.stringify({ + logs: [{ message: 'wifi connected', level: 'warn', created_at: 1790000000 }], + }), + }, + ), + ); + + expect(response.status).toBe(204); + const entries = await prisma.deviceLog.findMany(); + expect(entries).toHaveLength(1); + expect(entries[0]?.message).toBe('wifi connected'); + expect(entries[0]?.level).toBe('WARN'); + }); + + it('answers 204 to a malformed body rather than making the device retry', async () => { + const { token } = await pair(); + const response = await log( + deviceRequest('/api/log', { 'Access-Token': token }, { method: 'POST', body: 'not json' }), + ); + expect(response.status).toBe(204); + expect(await prisma.deviceLog.count()).toBe(0); + }); + + it('caps how much a single call can write', async () => { + const { token } = await pair(); + await log( + deviceRequest( + '/api/log', + { 'Access-Token': token }, + { + method: 'POST', + body: JSON.stringify({ + logs: Array.from({ length: 200 }, (_, index) => ({ message: `line ${index}` })), + }), + }, + ), + ); + expect(await prisma.deviceLog.count()).toBe(50); + }); + }); +}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..0e74c0b --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,50 @@ +import { prisma } from '@/lib/db'; + +/** Integration tests need a real database; without one they refuse to run. */ +export const hasDatabase = Boolean(process.env.TEST_DATABASE_URL); + +const WEEK = [ + { dayOfWeek: 0, isClosed: true, slots: [] }, + { dayOfWeek: 1, isClosed: true, slots: [] }, + { dayOfWeek: 2, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { + dayOfWeek: 3, + isClosed: false, + slots: [ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, + ], + }, + { dayOfWeek: 4, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { dayOfWeek: 5, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { dayOfWeek: 6, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, +]; + +/** Empties every table and re-seeds the reference week. */ +export async function resetDatabase(): Promise { + await prisma.deviceLog.deleteMany(); + await prisma.device.deleteMany(); + await prisma.screenImage.deleteMany(); + await prisma.scheduleException.deleteMany(); + await prisma.vacationPeriod.deleteMany(); + await prisma.publicHoliday.deleteMany(); + await prisma.message.deleteMany(); + await prisma.weeklySchedule.deleteMany(); + await prisma.settings.deleteMany(); + + await prisma.settings.create({ data: { id: 'singleton' } }); + for (const day of WEEK) { + await prisma.weeklySchedule.create({ data: day }); + } +} + +export function deviceRequest( + path: string, + headers: Record = {}, + init: RequestInit = {}, +): Request { + return new Request(`https://trmnl.example.test${path}`, { + headers: { host: 'trmnl.example.test', ...headers }, + ...init, + }); +} diff --git a/tests/setup-database.ts b/tests/setup-database.ts new file mode 100644 index 0000000..99d6353 --- /dev/null +++ b/tests/setup-database.ts @@ -0,0 +1,14 @@ +// Vitest does not read .env on its own. +import 'dotenv/config'; + +/** + * Points the integration tests at their own database. + * + * They truncate every table, so they must never run against the development + * database. When TEST_DATABASE_URL is unset the tests skip themselves rather + * than quietly destroying whatever DATABASE_URL happens to point at. + */ + +if (process.env.TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +} diff --git a/vitest.config.mts b/vitest.config.mts index 9637f4f..665fff6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -10,6 +10,9 @@ export default defineConfig({ test: { environment: 'node', include: ['lib/**/*.test.ts', 'lib/**/*.test.tsx', 'tests/**/*.test.ts'], + // Runs before any module is imported, so the Prisma client is built + // against the test database rather than the development one. + setupFiles: ['tests/setup-database.ts'], coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'],