Files
vliaudatandClaude Opus 5 767c6b9d77 fix: serve the device paths with or without a trailing slash
The panel's HTTP client does not follow redirects. It asks for
/api/setup/ with a trailing slash, Next answered 308 to normalise it,
and the firmware reported "returned code is not OK. Code - 308" and gave
up. Never having obtained a token, it then called /api/display with an
empty one, got 401, and told the user it could not reach the API.

Not TLS, not the network, not the port — a slash. Two earlier fixes were
aimed at hypotheses the evidence did not support: a certificate chain
the firmware genuinely cannot validate, and a port the shop's network
turned out not to block. Both were reasoned from silence, because
neither Traefik nor a production Next server logs requests by default.
The answer came from a packet capture, and from the device's own words.

/api/log now accepts a report from a panel that cannot authenticate.
Refusing it with a 401 threw away the one diagnostic that mattered: the
firmware was saying exactly what was wrong and we were discarding the
message. Nothing is stored — the rows would reference a device that does
not exist — but it reaches the server log, and the route was already
rate-limited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 22:43:39 +02:00

284 lines
10 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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('accepts a log from a device that cannot authenticate, without storing it', async () => {
// A panel that cannot authenticate is precisely the panel whose account
// of the failure is worth having: this is how a 308 on /api/setup/ was
// finally diagnosed, after a 401 had been discarding the evidence.
const response = await log(
deviceRequest(
'/api/log',
{ ID: MAC },
{ method: 'POST', body: '{"logs":[{"message":"returned code is not OK. Code - 308"}]}' },
),
);
expect(response.status).toBe(204);
expect(await prisma.deviceLog.count()).toBe(0);
});
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);
});
});
});