- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information. - Added `PassengerSettings.tsx` for managing user-defined important places and fare settings. - Implemented validation functions for passenger settings in `passenger-settings.mjs`. - Created utility functions for transliterating stop names in `transliteration.ts`. - Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities. - Established a mock server for testing passenger-related features in `passenger-server.mjs`. - Added comprehensive tests for passenger guide logic and transliteration functionality.
76 lines
6.9 KiB
JavaScript
76 lines
6.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { hydrateManagedState, 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})`)();
|
|
// This fixture's device belongs to city 2; Polotsk tariffs must not leak into it.
|
|
defaults.fareByType = [];
|
|
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);
|
|
const legacy = structuredClone(value); delete legacy.device.settings.nightMode;delete legacy.device.settings.nightWakeMinutes;
|
|
assert.equal(isManagedState(legacy),true);assert.equal(hydrateManagedState(legacy).device.settings.nightMode,'auto');
|
|
assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,nightWakeMinutes:5}}}),false);
|
|
});
|
|
test('managed transliteration settings accept legacy devices and reject malformed flags', () => {
|
|
const legacy = structuredClone(value); delete legacy.device.settings.journeyTranslitEnabled;
|
|
assert.equal(isManagedState(legacy), true);
|
|
assert.equal(hydrateManagedState(legacy).device.settings.journeyTranslitEnabled, false);
|
|
const enabled = { ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: true } } };
|
|
assert.equal(isManagedState(enabled), true);
|
|
assert.equal(hydrateManagedState(enabled).device.settings.journeyTranslitEnabled, true);
|
|
for (const flag of ['true', 1, null]) {
|
|
assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: flag } } }), false);
|
|
}
|
|
});
|
|
test('managed passenger settings migrate without leaking another city tariff or accepting damaged arrays', () => {
|
|
const legacy = structuredClone(value);
|
|
for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete legacy.device.settings[key];
|
|
assert.equal(isManagedState(legacy),true);assert.deepEqual(hydrateManagedState(legacy).device.settings.fareByType,[]);
|
|
const polotsk={...legacy,device:{...legacy.device,cityId:1},config:{...legacy.config,cityId:1}};
|
|
assert.equal(hydrateManagedState(polotsk).device.settings.fareByType[0].price,'1,05 BYN'); assert.equal(isManagedState(polotsk),true);
|
|
for(const patch of [{places:null},{fareRules:[{routeId:1}]},{weatherHintEnabled:'true'},{fareByType:[{cityId:1,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]}]) {
|
|
assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,...patch}}}),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')));
|
|
});
|