Add telemetry dashboard and automatic server names; record Workshop 0.0.137
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import {dashboardApp} from '../src/dashboard-client.js';
|
||||
const clientScript = process.env.DASHBOARD_BUNDLE
|
||||
? await (await (await import('../dist/index.js')).default.fetch(new Request('https://telemetry.example/dashboard/app.js'), {})).text()
|
||||
: `(${dashboardApp.toString()})();`;
|
||||
|
||||
// Minimal DOM boundary for client behavior; not a browser layout test.
|
||||
class Element {
|
||||
constructor(tag = 'div') { this.tagName=tag.toUpperCase(); this.children=[]; this.listeners={}; this.dataset={}; this.attributes={}; this.hidden=false; this.value=''; this.open=false; this._text=''; }
|
||||
set textContent(value) { this._text=String(value); this.children=[]; }
|
||||
get textContent() { return this._text+this.children.map(x=>x.textContent).join(' '); }
|
||||
set innerHTML(_) { throw new Error('HTML interpolation forbidden'); }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this._text=''; this.children=nodes; }
|
||||
addEventListener(type, handler) { this.listeners[type]=handler; }
|
||||
setAttribute(key,value) { this.attributes[key]=value; }
|
||||
removeAttribute(key) { delete this.attributes[key]; }
|
||||
closest(selector) { return selector==='button'&&this.tagName==='BUTTON'?this:null; }
|
||||
querySelector(selector) { return descendants(this).find(x=>x.tagName.toLowerCase()===selector)||null; }
|
||||
showModal() { this.open=true; }
|
||||
close() { this.open=false; this.listeners.close?.(); }
|
||||
focus() {}
|
||||
}
|
||||
const descendants = node => node.children.flatMap(child=>[child,...descendants(child)]);
|
||||
const tick = async () => { for(let i=0;i<8;i++) await new Promise(resolve=>setImmediate(resolve)); };
|
||||
function fixture() {
|
||||
const ids=Object.fromEntries(['token','login','login-form','login-error','app','metrics','content','updated','error','scope-label','clear-scope','tabs','refresh','logout','mission-dialog','mission-heading','mission-body','close-dialog'].map(id=>[id,new Element()]));
|
||||
ids['login-form'].append(new Element('button')); ids.app.hidden=true;
|
||||
for(const view of ['overview','servers','missions','players']) { const node=new Element('button'); node.dataset.view=view; ids.tabs.append(node); }
|
||||
const sid='11111111-1111-4111-8111-111111111111',mid='22222222-2222-4222-8222-222222222222',malicious='<img src=x onerror=alert(1)>';
|
||||
const summary={generatedAt:1793361000,totals:{servers:1,missions:1,events:3,wins:1,losses:0,unfinished:0,partialMissions:0,winRate:100,lastReceivedAt:1793361000},outcomes:[{outcome:'victory',missions:1}],maps:[{terrain:'Everon',missions:1,wins:1,losses:0,unfinished:0}]};
|
||||
const event={serverId:sid,missionId:mid,serverName:malicious,terrain:'Everon',outcome:'victory',phase:5,elapsedSeconds:120,occurredAt:1793360000,connectedPlayers:1,peakPlayers:1,gameVersion:'1.6',scenario:'Escape',previousOutcome:'',reason:malicious,partial:false,droppedEvents:0,mods:[{name:malicious,version:'1',id:'mod'}],settings:{roadblockCount:4},players:[{id:sid,name:malicious,authenticated:true,connected:true,participated:true,escaped:true,connectedSeconds:120,kills:2,friendlyKills:0}]};
|
||||
const requests=[]; let fail=false;
|
||||
const document={getElementById:id=>ids[id],createElement:tag=>new Element(tag),createTextNode:text=>{const node=new Element('#text');node.textContent=text;return node;},querySelectorAll:()=>ids.tabs.children,hidden:false};
|
||||
const fetch=async(path,options)=>{
|
||||
requests.push({path,options});
|
||||
if(fail)return{ok:false,status:503,json:async()=>({error:'Temporarily unavailable'})};
|
||||
if(options.headers.Authorization!=='Bearer valid-read-token-0123456789abcdef')return{ok:false,status:401,json:async()=>({})};
|
||||
let data;
|
||||
if(path.startsWith('/api/dashboard'))data=summary;
|
||||
else if(path.startsWith('/api/servers'))data={servers:[{serverId:sid,name:malicious,wins:1,losses:0,missions:1,unfinished:0,lastReceivedAt:1793361000,lastEventType:'heartbeat',lastReportedPlayers:1,lastMissionId:mid,terrain:'Everon',active:1,registration:'automatic'}],nextBefore:7};
|
||||
else if(path.includes('/events?'))data={events:[{receivedAt:1793361000,event}],nextBefore:null};
|
||||
else if(path.startsWith(`/api/missions/${sid}/`))data={receivedAt:1793361000,event};
|
||||
else if(path.startsWith('/api/missions?'))data={missions:[{...event,receivedAt:1793361000}],nextBefore:null};
|
||||
else if(path.startsWith('/api/players'))data={players:[{playerId:sid,attempts:1,escapes:1,didNotEscape:0,bestEscapeSeconds:120,kills:2,missionsWithKills:1,friendlyKills:0,missionsWithFriendlyKills:1}],nextAfter:null};
|
||||
else throw new Error('Unexpected path '+path);
|
||||
return{ok:true,status:200,json:async()=>data};
|
||||
};
|
||||
runInNewContext(clientScript,{document,Node:Element,fetch,AbortController,URLSearchParams,Date,setInterval:()=>{throw new Error("Automatic refresh must remain disabled");},setTimeout,URL,Blob});
|
||||
const click=async(node,target=node)=>{node.listeners.click?.({target});await tick();};
|
||||
const button=(root,text)=>{const node=descendants(root).find(x=>x.tagName==='BUTTON'&&x.textContent===text);assert.ok(node,`Button ${text}`);return node;};
|
||||
const login=async token=>{ids.token.value=token;await ids['login-form'].listeners.submit({preventDefault(){},target:ids['login-form']});await tick();};
|
||||
return{ids,requests,sid,malicious,click,button,login,tab:async view=>click(ids.tabs,ids.tabs.children.find(x=>x.dataset.view===view)),fail:()=>{fail=true;}};
|
||||
}
|
||||
|
||||
test('client login, scope, pagination, mission details, safe text and logout',async()=>{
|
||||
const f=fixture();await f.login('wrong-token');assert.match(f.ids['login-error'].textContent,/Token rejected/);assert.equal(f.ids.app.hidden,true);
|
||||
await f.login('valid-read-token-0123456789abcdef');assert.equal(f.ids.app.hidden,false);assert.equal(f.ids.token.value,'');assert.match(f.ids.metrics.textContent,/100%/);
|
||||
await f.tab('servers');assert.match(f.ids.content.textContent,/Last received/);assert.ok(f.ids.content.textContent.includes(f.malicious));assert.equal(descendants(f.ids.content).some(x=>x.tagName==='IMG'),false);
|
||||
await f.click(f.button(f.ids.content,'Next →'));assert.ok(f.requests.some(r=>r.path.includes('before=7')));
|
||||
await f.click(f.button(f.ids.content,f.malicious));assert.ok(f.requests.some(r=>r.path.startsWith('/api/missions?')&&r.path.includes(`serverId=${f.sid}`)));
|
||||
await f.click(f.button(f.ids.content,'Everon'));assert.equal(f.ids['mission-dialog'].open,true);assert.match(f.ids['mission-body'].textContent,/Mission settings/);assert.match(f.ids['mission-body'].textContent,/Event history/);assert.ok(f.ids['mission-body'].textContent.includes(f.malicious));
|
||||
await f.click(f.ids['close-dialog']);assert.equal(f.ids['mission-dialog'].open,false);
|
||||
await f.tab('players');assert.match(f.ids.content.textContent,/1\/1 missions reported this counter/);
|
||||
await f.click(f.ids.logout);assert.equal(f.ids.app.hidden,true);assert.equal(f.ids.content.textContent,'');assert.equal(f.ids['mission-body'].textContent,'');
|
||||
for(const request of f.requests)assert.ok(!request.path.includes('token'));
|
||||
});
|
||||
test('failed refresh removes stale records and explains retry',async()=>{
|
||||
const f=fixture();await f.login('valid-read-token-0123456789abcdef');f.fail();await f.click(f.ids.refresh);
|
||||
assert.equal(f.ids.error.hidden,false);assert.match(f.ids.updated.textContent,/Refresh failed/);assert.equal(f.ids.metrics.textContent,'');assert.match(f.ids.content.textContent,/Use Refresh/);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ async function detail(e) {
|
||||
}
|
||||
before(async () => {
|
||||
mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry',
|
||||
modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
||||
modules: ['index.js', 'validation.js', 'dashboard-api.js', 'dashboard.js', 'dashboard-client.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
||||
compatibilityDate: '2026-10-23', d1Databases: ['DB'],
|
||||
ratelimits: {
|
||||
REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}},
|
||||
@@ -302,3 +302,99 @@ test('map records count victories and defeats across servers, excluding other ma
|
||||
const row = maps.maps.find(m => m.terrain === terrain.toLowerCase());
|
||||
assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1});
|
||||
});
|
||||
|
||||
test('dashboard shell is public, same-origin and contains no telemetry or credentials', async () => {
|
||||
for (const path of ['/dashboard', '/dashboard/', '/dashboard/app.js', '/dashboard/style.css']) {
|
||||
const response = await call(path);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('content-security-policy'), /frame-ancestors 'none'/);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const content = await response.text();
|
||||
assert.ok(!content.includes(ADMIN) && !content.includes(READ) && !content.includes(token));
|
||||
}
|
||||
assert.equal((await call('/dashboard', {method: 'POST'})).status, 405);
|
||||
assert.equal((await call('/api/dashboard')).status, 401);
|
||||
assert.equal((await call('/api/servers')).status, 401);
|
||||
assert.equal((await call('/api/dashboard', {headers: {authorization: `Bearer ${token}`}})).status, 401);
|
||||
assert.equal((await call('/api/servers', {headers: readAuth})).status, 200);
|
||||
assert.equal((await call(`/api/servers/${serverId}`, {method: 'DELETE', headers: readAuth})).status, 401);
|
||||
});
|
||||
|
||||
test('dashboard receipt freshness uses raw arrivals, independently of terminal snapshot selection', async () => {
|
||||
const sid = crypto.randomUUID(), writeToken = 'dashboard-installation-secret-0123456789';
|
||||
const complete = event({serverId: sid, serverName: '<img src=x onerror=alert(1)>', terrain: 'Everon dashboard', players: []});
|
||||
assert.equal((await ingest(complete, writeToken)).status, 201);
|
||||
const finalReceipt = (await db.prepare('SELECT received_at FROM missions WHERE server_id = ?').bind(sid).first()).received_at;
|
||||
const delayed = {...complete, eventId: crypto.randomUUID(), type: 'heartbeat', phase: 1, outcome: '', occurredAt: 1, elapsedSeconds: 10};
|
||||
assert.equal((await ingest(delayed, writeToken)).status, 201);
|
||||
await db.prepare('UPDATE events SET received_at = ? WHERE server_id = ? AND event_id = ?').bind(finalReceipt + 40, sid, delayed.eventId).run();
|
||||
const rows = await (await call(`/api/servers?q=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(rows.servers.length, 1);
|
||||
const server = rows.servers[0];
|
||||
assert.equal(server.lastReceivedAt, finalReceipt + 40);
|
||||
assert.equal(server.lastReportedAt, 1);
|
||||
assert.equal(server.lastEventType, 'heartbeat');
|
||||
assert.equal(server.wins, 1); assert.equal(server.losses, 0); assert.equal(server.unfinished, 0);
|
||||
assert.equal(server.lastMissionId, complete.missionId);
|
||||
assert.equal(server.name, complete.serverName); // Stored literally; browser renders as text.
|
||||
assert.ok(!JSON.stringify(rows).includes('token_hash'));
|
||||
const snapshot = await (await call(`/api/missions/${sid}/${complete.missionId}`, {headers: readAuth})).json();
|
||||
assert.equal(snapshot.receivedAt, finalReceipt); assert.equal(snapshot.event.outcome, 'victory');
|
||||
const summary = await (await call(`/api/dashboard?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(summary.totals.missions, 1); assert.equal(summary.totals.events, 2);
|
||||
assert.equal(summary.totals.wins, 1); assert.equal(summary.totals.winRate, 100);
|
||||
assert.equal(summary.totals.servers, 1); assert.equal(summary.totals.lastReceivedAt, finalReceipt + 40);
|
||||
assert.deepEqual(summary.maps.map(m => m.terrain), ['everon dashboard']);
|
||||
assert.equal((await ingest(delayed, writeToken)).status, 200);
|
||||
const duplicateSummary = await (await call(`/api/dashboard?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(duplicateSummary.totals.events, 2);
|
||||
const empty = await (await call(`/api/dashboard?serverId=${crypto.randomUUID()}`, {headers: readAuth})).json();
|
||||
assert.equal(empty.totals.lastReceivedAt, null); assert.equal(empty.totals.winRate, null);
|
||||
assert.equal(empty.totals.missions, 0); assert.deepEqual(empty.maps, []);
|
||||
});
|
||||
|
||||
test('dashboard server pagination, never-reporting servers and exact terrain filters', async () => {
|
||||
const sid = crypto.randomUUID();
|
||||
assert.equal((await post('/api/servers', {serverId: sid, name: 'Never reported'}, auth)).status, 201);
|
||||
const first = await (await call('/api/servers?limit=1', {headers: readAuth})).json();
|
||||
assert.equal(first.servers[0].serverId, sid);
|
||||
assert.equal(first.servers[0].lastReceivedAt, null); assert.equal(first.servers[0].missions, 0);
|
||||
const next = await (await call(`/api/servers?limit=1&before=${first.nextBefore}`, {headers: readAuth})).json();
|
||||
assert.notEqual(next.servers[0].serverId, sid);
|
||||
const matched = await (await call('/api/missions?terrain=EVERON%20DASHBOARD', {headers: readAuth})).json();
|
||||
assert.equal(matched.missions.length, 1); assert.equal(matched.missions[0].outcome, 'victory');
|
||||
const empty = await (await call('/api/missions?terrain=Missing%20island', {headers: readAuth})).json();
|
||||
assert.equal(empty.missions.length, 0);
|
||||
assert.equal((await call('/api/servers?q=' + 'a'.repeat(121), {headers: readAuth})).status, 400);
|
||||
assert.equal((await call('/api/dashboard?serverId=bad', {headers: readAuth})).status, 400);
|
||||
});
|
||||
|
||||
test('automatic server names arrive later, follow renames, and survive blanks, retries and old queued reports', async () => {
|
||||
const sid = crypto.randomUUID(), secret = 'server-name-test-credential-0123456789';
|
||||
const original = event({serverId: sid, serverName: '', players: [], occurredAt: 100});
|
||||
assert.equal((await ingest(original, secret)).status, 201);
|
||||
const name = async () => (await db.prepare('SELECT name FROM servers WHERE server_id = ?').bind(sid).first()).name;
|
||||
assert.equal(await name(), '');
|
||||
const report = (serverName, occurredAt) => event({serverId: sid, serverName, occurredAt, players: []});
|
||||
const first = report(' Community Escape ', 200);
|
||||
assert.equal((await ingest(first, secret)).status, 201); assert.equal(await name(), 'Community Escape');
|
||||
const renamed = report('New name', 300);
|
||||
assert.equal((await ingest(renamed, secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report(' ', 400), secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report('Old name', 150), secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(first, secret)).status, 200); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest({...renamed, serverName: 'Conflict'}, secret)).status, 409); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report('Impersonated', 500), 'wrong-credential-0123456789abcdef123456')).status, 401);
|
||||
assert.equal(await name(), 'New name');
|
||||
const listing = await (await call(`/api/servers?q=${encodeURIComponent('New name')}`, {headers: readAuth})).json();
|
||||
assert.equal(listing.servers[0].serverId, sid);
|
||||
const missions = await (await call(`/api/missions?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(missions.missions.find(m => m.missionId === original.missionId).serverName, 'New name');
|
||||
assert.equal(missions.missions.find(m => m.missionId === first.missionId).serverName, 'Community Escape');
|
||||
const snapshot = await (await call(`/api/missions/${sid}/${original.missionId}`, {headers: readAuth})).json();
|
||||
assert.equal(snapshot.serverName, 'New name'); assert.equal(snapshot.event.serverName, ''); // No historical payload rewriting.
|
||||
const managed = crypto.randomUUID();
|
||||
const registered = await (await post('/api/servers', {serverId: managed, name: 'Operator label'}, auth)).json();
|
||||
assert.equal((await ingest(event({serverId: managed, serverName: 'Game name', players: []}), registered.token)).status, 201);
|
||||
assert.equal((await db.prepare('SELECT name FROM servers WHERE server_id = ?').bind(managed).first()).name, 'Operator label');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user