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
284 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|