74 lines
7.3 KiB
JavaScript
74 lines
7.3 KiB
JavaScript
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/);
|
|
});
|