feat: add remote device management and pairing functionality

- Implemented device pairing with a four-digit code and TTL for remote settings.
- Introduced automatic checks for device status and configuration validation.
- Developed UI components for device setup and management, including error handling and retry options.
- Created a device bridge for secure communication between the device and the server.
- Added tests for managed state validation and device pairing logic.
- Established a local end-to-end fixture for testing device service and API interactions.
This commit is contained in:
2026-10-10 00:52:36 +03:00
parent e3074555a7
commit d04b364047
21 changed files with 402 additions and 10 deletions

49
test/device.test.mjs Normal file
View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { isManagedState } from '../src/lib/device.mjs';
import { createDeviceBridge } from '../server/device.mjs';
import { createApp } from '../server/index.mjs';
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
// Settings used by the actual board; no external project is required for these tests.
const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1];
const defaults = Function(`return (${body})`)();
const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } };
test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => {
assert.equal(isManagedState(value), true);
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
assert.equal(isManagedState(null), false);
});
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
let revoked = false;
const bridge = createDeviceBridge({ enabled: true, baseUrl: 'https://api.example/', fetchData: async (url, init) => {
calls.push({ path: url.pathname, token: init.headers['X-Board-Token'], body: init.body });
if (url.pathname.endsWith('/pairings')) return Response.json({ token: tokenA, state: 'pending', code: '0847', expiresAt: new Date(Date.now()+300000).toISOString() });
if (revoked) return Response.json({ error: 'secret raw upstream' }, { status: 410 });
if (url.pathname.endsWith('/heartbeat')) return Response.json({ ok: true });
const cityId = init.headers['X-Board-Token'] === tokenB ? 2 : 1;
return Response.json({ state: 'paired', device: { ...value.device, cityId, cityName: `City ${cityId}`, stopName: 'Stop', settings: { ...defaults, stopId: cityId } } });
} });
const factoryCalls = [], factory = ({cityId}) => ({ config: () => ({cityId}), board: async stopId => ({cityId,stop:{id:stopId}}) });
const defaultClient = { config: () => ({ ...value.config, cityId: 1 }), board: async () => assert.fail('managed requests must use the per-city client') };
const server = createApp(defaultClient, {}, bridge, options => { factoryCalls.push(options.cityId); return factory(options); }).listen(0,'127.0.0.1');
await new Promise((r,j)=>{server.once('listening',r);server.once('error',j)});t.after(()=>new Promise(r=>server.close(r)));
const base=`http://127.0.0.1:${server.address().port}`;
const request=(path, opts={})=>fetch(base+path,opts);
assert.equal((await request('/api/board?stopId=1')).status,401);
const pair = await request('/api/device/pair', { method:'POST',headers:{'Content-Type':'application/json',Origin:base},body:'{}' });
assert.equal(pair.status,200); assert.equal((await pair.json()).token,undefined);
const cookie = pair.headers.get('set-cookie');assert.match(cookie,/HttpOnly/);assert.match(cookie,/SameSite=Strict/);
assert.equal((await request('/api/device/pair',{method:'POST',headers:{'Content-Type':'application/json',Origin:'https://evil.example'},body:'{}'})).status,403);
const a={Cookie:`ridebus_board_device=${tokenA}`},b={Cookie:`ridebus_board_device=${tokenB}`};
assert.equal((await (await request('/api/device/status',{headers:a})).json()).config.cityId,1);
assert.equal((await (await request('/api/board?stopId=1',{headers:a})).json()).cityId,1);
assert.equal((await (await request('/api/board?stopId=2',{headers:b})).json()).cityId,2);
assert.deepEqual(factoryCalls,[1,2]);assert.equal((await request('/api/board?stopId=2',{headers:a})).status,409);
assert.equal((await request('/api/device/heartbeat',{method:'POST',headers:{...a,'Content-Type':'application/json'},body:'{"appliedRevision":1}'})).status,200);
revoked=true; const result=await request('/api/device/status',{headers:a}); assert.equal(result.status,410);assert.deepEqual(await result.json(),{error:'device_revoked'});
assert.equal((await request('/api/board?stopId=1',{headers:a})).status,410);
assert.ok(calls.every(c=>!c.path.includes('/admin')));
});

43
test/fixtures/device-flow-server.mjs vendored Normal file
View File

@@ -0,0 +1,43 @@
// Local end-to-end fixture: real device service, router, admin UI and board bridge.
// The database and transport data are explicit test fixtures. Never deploy this file.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { readFile } from 'node:fs/promises';
import { createApp } from '../../server/index.mjs';
import { createDeviceBridge } from '../../server/device.mjs';
import { createDemo } from '../../src/lib/demo.ts';
const apiRequire = createRequire(new URL('../../../polotsk-transit-api/backend/package.json', import.meta.url));
process.env.JWT_SECRET = 'local-board-fixture-only-never-production';
apiRequire.cache[apiRequire.resolve('./src/config/database.js')] = { exports: { query: async () => ({ rows: [] }) } };
const express = apiRequire('express'), jwt = apiRequire('jsonwebtoken');
const { createBoardDevicesRouter } = apiRequire('./src/routes/boardDevices.js');
const { createBoardDeviceService } = apiRequire('./src/services/boardDeviceService.js');
const { memoryRepository } = apiRequire('./test/helpers/boardDeviceMemoryRepository.js');
const repository = memoryRepository(), service = createBoardDeviceService(repository);
const api = express(); api.use(express.json());
let managementUnavailable = false;
api.post('/__fixture/connection', (req,res) => { managementUnavailable = req.body.offline === true; res.json({ offline: managementUnavailable }); });
api.use('/api/v1/board-devices', (_req,res,next) => managementUnavailable ? res.status(503).json({error:'fixture_offline'}) : next());
const user = { id: 1, username: 'board-fixture', full_name: 'Тестовый оператор', role: 'admin' };
api.post('/api/v1/auth/login', (req, res) => { if(req.body.username !== 'board-fixture' || req.body.password !== 'fixture-only') return res.status(401).json({error:'Тестовый логин: board-fixture / fixture-only'}); res.json({ user, accessToken:jwt.sign(user,process.env.JWT_SECRET,{expiresIn:'1h'}),refreshToken:'local-fixture-refresh' }); });
api.get('/api/v1/auth/me', (_req,res)=>res.json(user));
api.post('/api/v1/auth/logout', (_req,res)=>res.json({ok:true}));
api.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req,res,next) => { const id=Number(req.get('X-City-Id')||1);if(![1,2].includes(id))return res.status(404).json({error:'City not found'});req.city={id};next(); } }));
api.get('/api/v1/cities', (_req,res)=>res.json([{id:1,name:'Полоцк',slug:'polotsk',emblem:'🏛️'},{id:2,name:'Новополоцк',slug:'novopolotsk',emblem:'🏙️'}]));
api.get('/api/v1/stops', (req,res)=>res.json(repository.stops.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
api.get('/api/v1/routes', (req,res)=>res.json(repository.routesData.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
api.get('/api/v1/alerts/active',(_req,res)=>res.json([]));api.get('/api/v1/holidays/upcoming',(_req,res)=>res.json([]));api.get('/api/v1/sync/status',(_req,res)=>res.json({status:'ready',database:{schedules_count:2}}));
const adminDist=fileURLToPath(new URL('../../../polotsk-transit-api/frontend/dist/',import.meta.url));
api.use(express.static(adminDist));api.get('*',async(_req,res)=>res.type('html').send((await readFile(`${adminDist}/index.html`,'utf8')).replace('</body>','<div style="position:fixed;bottom:6px;right:10px;z-index:1000;background:#fff8;padding:4px;font-size:11px">Тестовый стенд · без рабочей БД</div></body>')));
await new Promise(r=>api.listen(5190,'127.0.0.1',r));
const makeClient=({cityId=1,cityName='Полоцк',defaultStopId=1,stopName='Швейная фабрика'}={})=>({
config:()=>({cityId,cityName,defaultStopId,stopName,configured:true,refreshSeconds:20,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}),
stops:async()=>repository.stops.filter(s=>s.city_id===cityId),
board:async stopId=>({...createDemo(),cityId,cityName,stop:{id:stopId,name:repository.stops.find(s=>s.id===stopId)?.name||stopName,direction:'Тестовые данные'},mode:'live'}),
timetable:async()=>{throw new Error('no timetable in fixture');},coordinates:async()=>null,
});
const bridge=createDeviceBridge({enabled:true,baseUrl:'http://127.0.0.1:5190/',adminUrl:'http://127.0.0.1:5190/devices'});
const board=createApp(makeClient(),{weather:async()=>{throw new Error('no weather in fixture');}},bridge,makeClient);
const boardDist=fileURLToPath(new URL('../../dist/',import.meta.url));board.use(express.static(boardDist));board.get('/{*path}',(_req,res)=>res.sendFile(`${boardDist}/index.html`));
await new Promise(r=>board.listen(5191,'127.0.0.1',r));
console.log('Device flow fixture: admin http://127.0.0.1:5190/devices; board http://127.0.0.1:5191/');