feat: implement board device service with CRUD operations and validation
- Add boardDeviceService for managing device pairing, status, claiming, and revocation. - Introduce boardDeviceSettings for default settings and validation logic. - Create integration tests for device lifecycle, concurrent claims, and updates. - Add unit tests for device pairing, claiming, and settings validation. - Implement API tests for device endpoints with JWT authentication. - Develop in-memory repository for testing purposes. - Create frontend components for managing devices, including a form for device settings. - Style DevicesPage with CSS for better user experience.
This commit is contained in:
24
backend/test/boardDevices.integration.test.js
Normal file
24
backend/test/boardDevices.integration.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { Pool } = require('pg');
|
||||
const { createBoardDeviceRepository } = require('../src/services/boardDeviceRepository');
|
||||
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
|
||||
const { defaults } = require('../src/services/boardDeviceSettings');
|
||||
test('PostgreSQL device lifecycle, concurrent claims/updates, city isolation and retry after a lost reply', { skip: !process.env.BOARD_DEVICE_TEST_DATABASE_URL }, async () => {
|
||||
const url = process.env.BOARD_DEVICE_TEST_DATABASE_URL; assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
|
||||
const admin = new Pool({ connectionString: url }); const schema = `board_device_test_${Date.now()}`; let pool;
|
||||
try {
|
||||
await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
|
||||
await pool.query('CREATE TABLE cities(id INTEGER PRIMARY KEY,name TEXT,is_active BOOLEAN); CREATE TABLE users(id INTEGER PRIMARY KEY); CREATE TABLE stops(id INTEGER PRIMARY KEY,city_id INTEGER REFERENCES cities(id),name TEXT,description TEXT); CREATE TABLE routes(id INTEGER PRIMARY KEY,city_id INTEGER,route_number TEXT,name TEXT); CREATE TABLE route_stops(route_id INTEGER,stop_id INTEGER); INSERT INTO cities VALUES(1,\'Полоцк\',true),(2,\'Новополоцк\',true); INSERT INTO users VALUES(1); INSERT INTO stops VALUES(1,1,\'Швейная фабрика\',\'на центр\'),(2,2,\'Другая\',\'конечная\');');
|
||||
const sql = await fs.readFile(path.join(__dirname, '../migrations/016_board_devices.sql'), 'utf8'); await pool.query(sql); await pool.query(sql);
|
||||
const service = createBoardDeviceService(createBoardDeviceRepository(pool)); const p = await service.start({ width: 1920 });
|
||||
const claims = await Promise.allSettled([service.claim(1,{code:p.code,name:'A',settings:defaults},1),service.claim(1,{code:p.code,name:'B',settings:defaults},1)]);
|
||||
assert.equal(claims.filter(r=>r.status==='fulfilled').length,1); const row=(await service.status(p.token)).device;
|
||||
assert.equal((await service.status(p.token)).device.id,row.id); assert.equal((await service.list(2)).length,0);
|
||||
const updates=await Promise.allSettled([service.update(row.id,1,{name:'C',settings:defaults,revision:1},1),service.update(row.id,1,{name:'D',settings:defaults,revision:1},1)]);assert.equal(updates.filter(r=>r.status==='fulfilled').length,1);
|
||||
await service.heartbeat(p.token,{appliedRevision:2});assert.equal((await service.list(1))[0].appliedRevision,2);
|
||||
await assert.rejects(service.revoke(row.id,2,1),e=>e.status===404);await service.revoke(row.id,1,1);await assert.rejects(service.status(p.token),e=>e.status===410);
|
||||
} finally { if(pool) await pool.end();await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);await admin.end(); }
|
||||
});
|
||||
51
backend/test/boardDevices.test.js
Normal file
51
backend/test/boardDevices.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createBoardDeviceService, tokenHash } = require('../src/services/boardDeviceService');
|
||||
const { defaults, validSettings } = require('../src/services/boardDeviceSettings');
|
||||
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
|
||||
test('device pairing expires, preserves leading zeroes, retries collisions and has a secret distinct from the display code', async () => {
|
||||
let now = Date.now(), codes = ['0847', '0847', '0009']; const repo = memoryRepository({ now: () => now });
|
||||
const service = createBoardDeviceService(repo, { now: () => now, makeCode: () => codes.shift() });
|
||||
const a = await service.start({ width: 879, height: 593, version: 'test' });
|
||||
assert.equal(a.code, '0847'); assert.match(a.token, /^[A-Za-z0-9_-]{43}$/); assert.equal(repo.pairings.get('0847').token_hash, tokenHash(a.token));
|
||||
assert.equal((await service.start({})).code, '0009');
|
||||
await assert.rejects(service.status('0847'), e => e.status === 401);
|
||||
now += 300001; assert.equal((await service.status(a.token)).state, 'expired');
|
||||
await assert.rejects(service.claim(1, { code: '0847', name: 'A', settings: defaults }, 1), e => e.status === 409);
|
||||
});
|
||||
test('only one concurrent claim succeeds and the same device credential survives retry/restart', async () => {
|
||||
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
|
||||
const input = { code: p.code, name: 'Улица', settings: defaults };
|
||||
const claims = await Promise.allSettled([service.claim(1, input, 1), service.claim(1, input, 2)]);
|
||||
assert.equal(claims.filter(c => c.status === 'fulfilled').length, 1);
|
||||
assert.equal((await service.status(p.token)).state, 'paired'); assert.equal((await service.status(p.token)).device.name, 'Улица');
|
||||
const json = JSON.stringify(await service.list(1)); assert.ok(!json.includes(p.token)); assert.ok(!json.includes(tokenHash(p.token)));
|
||||
});
|
||||
test('city/stop/route validation happens before consuming a pairing code', async () => {
|
||||
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
|
||||
for (const settings of [{ ...defaults, stopId: 3 }, { ...defaults, routeFilter: '2' }, { ...defaults, rowCount: 7 }, { ...defaults, colorScheme: 'sky' }]) await assert.rejects(service.claim(1, { code: p.code, name: 'A', settings }, 1), e => e.status === 400);
|
||||
assert.equal((await service.status(p.token)).state, 'pending'); await service.claim(2, { code: p.code, name: 'B', settings: { ...defaults, stopId: 3, routeFilter: '2' } }, 1);
|
||||
assert.equal((await service.list(1)).length, 0); assert.equal((await service.list(2)).length, 1);
|
||||
assert.deepEqual(await service.routes(2, 3), [{ id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }]);
|
||||
});
|
||||
test('configuration revisions are optimistic and applied/online state comes from device acknowledgement', async () => {
|
||||
let now = Date.now(); const service = createBoardDeviceService(memoryRepository({ now: () => now }), { now: () => now }); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
|
||||
const input = { name: 'A', revision: 1, settings: { ...defaults, theme: 'light', lightColorScheme: 'forest', infoQrEnabled: false } };
|
||||
await assert.rejects(service.update(d.id, 2, { ...input, settings: { ...defaults, stopId: 3 } }, 1), e => e.status === 409);
|
||||
const r = await Promise.allSettled([service.update(d.id, 1, input, 1), service.update(d.id, 1, input, 1)]); assert.equal(r.filter(x => x.status === 'fulfilled').length, 1);
|
||||
let row = (await service.list(1))[0]; assert.equal(row.revision, 2); assert.equal(row.appliedRevision, 0); assert.equal(row.online, false);
|
||||
await service.heartbeat(p.token, { appliedRevision: 2, width: 1920, height: 1080 }); row = (await service.list(1))[0]; assert.equal(row.appliedRevision, 2); assert.equal(row.online, true);
|
||||
await service.heartbeat(p.token, { appliedRevision: 1 }); assert.equal((await service.list(1))[0].appliedRevision, 2);
|
||||
await assert.rejects(service.heartbeat(p.token, { appliedRevision: 999 }), e => e.status === 400);
|
||||
now += 90001; assert.equal((await service.list(1))[0].online, false);
|
||||
});
|
||||
test('revocation rejects device access and is scoped to the selected city', async () => {
|
||||
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
|
||||
await assert.rejects(service.revoke(d.id, 2, 1), e => e.status === 404); await service.revoke(d.id, 1, 1);
|
||||
await assert.rejects(service.status(p.token), e => e.status === 410); await assert.rejects(service.heartbeat(p.token, { appliedRevision: 1 }), e => e.status === 410);
|
||||
});
|
||||
test('remote settings are bounded, preserve supported palettes and cannot enable demo or accept unknown properties', () => {
|
||||
assert.equal(validSettings({ ...defaults, demo: true }).demo, false);
|
||||
for (const bad of [{ ...defaults, holdNear: 'yes' }, { ...defaults, adText: 'x'.repeat(201) }, { ...defaults, toString: 'bad' }, { ...defaults, stopId: NaN }, { ...defaults, routeFilter: '-1' }]) assert.throws(() => validSettings(bad), e => e.status === 400);
|
||||
assert.equal(validSettings({ ...defaults, colorScheme: 'cyan' }).colorScheme, 'cyan');
|
||||
});
|
||||
28
backend/test/boardDevicesApi.test.js
Normal file
28
backend/test/boardDevicesApi.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
process.env.JWT_SECRET = 'board-device-test-secret-only';
|
||||
require.cache[require.resolve('../src/config/database')] = { exports: { query: async () => ({ rows: [] }) } };
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { createBoardDevicesRouter } = require('../src/routes/boardDevices');
|
||||
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
|
||||
const { defaults } = require('../src/services/boardDeviceSettings');
|
||||
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
|
||||
test('public pairing and device credentials cannot access admin endpoints; JWT administrator and city are required', async t => {
|
||||
const service = createBoardDeviceService(memoryRepository()); const app = express(); app.use(express.json());
|
||||
app.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req, _res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1) }; next(); } }));
|
||||
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); }); t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
const base = `http://127.0.0.1:${server.address().port}/api/v1/board-devices`;
|
||||
const post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
|
||||
const admin = { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'admin' }, process.env.JWT_SECRET)}`, 'X-City-Id': '1' };
|
||||
const pair = await (await post('/pairings', { width: 879, height: 593 })).json();
|
||||
assert.match(pair.code, /^\d{4}$/); assert.equal((await fetch(base + '/admin')).status, 401);
|
||||
assert.equal((await fetch(base + '/admin', { headers: { 'X-Board-Token': pair.token } })).status, 401);
|
||||
assert.equal((await fetch(base + '/admin', { headers: { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'user' }, process.env.JWT_SECRET)}` } })).status, 403);
|
||||
assert.equal((await fetch(base + '/self', { headers: admin })).status, 401);
|
||||
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 200);
|
||||
const status = await fetch(base + '/self', { headers: { 'X-Board-Token': pair.token } }); assert.equal(status.headers.get('cache-control'), 'no-store'); assert.equal((await status.json()).state, 'paired');
|
||||
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 409);
|
||||
const list = await (await fetch(base + '/admin', { headers: admin })).json(); assert.equal(list.length, 1); assert.equal(list[0].token_hash, undefined);
|
||||
assert.equal((await (await fetch(base + '/admin', { headers: { ...admin, 'X-City-Id': '2' } })).json()).length, 0);
|
||||
});
|
||||
21
backend/test/helpers/boardDeviceMemoryRepository.js
Normal file
21
backend/test/helpers/boardDeviceMemoryRepository.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Explicit test-only repository; production always uses PostgreSQL.
|
||||
function memoryRepository({ now = Date.now } = {}) {
|
||||
const pairings = new Map(), devices = new Map();
|
||||
const stops = [{ id: 1, city_id: 1, name: 'Швейная фабрика', description: 'на Богатырскую' }, { id: 2, city_id: 1, name: 'Автовокзал', description: 'в центр' }, { id: 3, city_id: 2, name: 'Новополоцк', description: 'конечная' }];
|
||||
const routes = [{ id: 1, city_id: 1, route_number: '1', name: 'Швейная фабрика — Богатырская', stops: [1, 2] }, { id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }];
|
||||
const join = row => ({ ...row, city_name: row.city_id === 1 ? 'Полоцк' : 'Новополоцк', stop_name: stops.find(s => s.id === row.stop_id)?.name, stop_direction: stops.find(s => s.id === row.stop_id)?.description });
|
||||
return {
|
||||
stops, routesData: routes, devices, pairings,
|
||||
async createPairing(p) { for (const [code, item] of pairings) if (Date.parse(item.expires_at) <= now()) pairings.delete(code); if (pairings.has(p.code)) { const e = new Error('collision'); e.code = '23505'; throw e; } pairings.set(p.code, p); return p; },
|
||||
async pairing(hash) { return [...pairings.values()].find(p => p.token_hash === hash); },
|
||||
async device(hash) { const row = [...devices.values()].find(d => d.token_hash === hash); return row && join(row); },
|
||||
async routes(cityId, stopId) { return routes.filter(r => r.city_id === cityId && r.stops.includes(stopId)); },
|
||||
async catalog(cityId, s) { return stops.some(stop => stop.id === s.stopId && stop.city_id === cityId) && (!s.routeFilter || routes.some(r => r.id === Number(s.routeFilter) && r.city_id === cityId && r.stops.includes(s.stopId))); },
|
||||
async claim(code, record) { const p = pairings.get(code); if (!p || Date.parse(p.expires_at) <= now()) return null; pairings.delete(code); const row = { ...record, token_hash: p.token_hash, device_info: p.device_info, revision: 1, applied_revision: 0, last_seen_at: null, revoked_at: null }; devices.set(row.id, row); return row; },
|
||||
async list(cityId) { return [...devices.values()].filter(d => d.city_id === cityId).map(join); },
|
||||
async update(id, cityId, input) { const d = devices.get(id); if (!d || d.city_id !== cityId || d.revision !== input.revision || d.revoked_at) return null; Object.assign(d, { name: input.name, stop_id: input.settings.stopId, settings: input.settings, revision: d.revision + 1 }); return d; },
|
||||
async revoke(id, cityId) { const d = devices.get(id); if (!d || d.city_id !== cityId) return null; d.revoked_at ||= new Date(now()).toISOString(); return d; },
|
||||
async heartbeat(hash, revision, info) { const d = [...devices.values()].find(d => d.token_hash === hash && !d.revoked_at); if (!d) return null; d.applied_revision = Math.max(d.applied_revision, Math.min(d.revision, revision)); d.last_seen_at = new Date(now()).toISOString(); d.device_info = info; return d; },
|
||||
};
|
||||
}
|
||||
module.exports = { memoryRepository };
|
||||
Reference in New Issue
Block a user