fix(host-control-mode): harden gates, close UX races, debounce toggles, surface desync state

Bis auf peerId-Auth (akzeptiert) alle Audit-Findings behoben:
- Snap-back spielt nicht mehr blind ab, wenn Host-Status unbekannt
- Teardown-Pfade broadcasten jetzt CONTROL_MODE → kein verwaistes Badge/Dialog
- Dialog-Timer bei Ersetzung sauber gecleart
- Server re-synchronisiert Sender bei nicht-Host-Reject
- Popup-Toggle revertiert bei Server-Ablehnung
- Desync-Zweig sendet keine Phantom-ACKs mehr an Host
- Toggle-Debouncing serverseitig (500ms pro Raum)
- Button-Text auf Unlock zurückgesetzt
- Live-Erkennung defensiver, Resync-Retry bei fehlendem Target, Badge-Retry

Neu: Host sieht 'Solo'-Badge für desyncte Gäste via PEER_STATUS-Heartbeat
(16 Sprachen, neuer i18n-Key BADGE_DESYNCED/TOOLTIP_PEER_DESYNCED)

Tests: H-5-Reject-Unicast, M-4-Debounce, Desync-Heartbeat-Relay
This commit is contained in:
KoalaDev
2026-06-26 13:07:17 +02:00
parent 69f8f4cfd7
commit 58af258fd3
20 changed files with 245 additions and 20 deletions
+51 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -23,6 +24,9 @@ function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith(
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
function resetConnectionRate() { connectionCounts.clear(); clearRateLimitMaps(); }
try {
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
@@ -57,6 +61,7 @@ try {
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
close();
resetConnectionRate();
// --- Host Control Mode ---
const hrid = 'host-'+Date.now();
@@ -77,10 +82,31 @@ try {
s(h1,'pause',{currentTime:7}); await w(h2,'pause');
h1._m.length = h2._m.length = 0;
// Guest cannot change the control mode -> no broadcast
// desynced flag is relayed through PEER_STATUS heartbeats so the host's UI
// can show "Solo" for guests watching on their own.
s(h2,'peer_status',{status:'heartbeat',desynced:true,currentTime:42,playbackState:'playing'});
let hbData = null; const hbStart = Date.now();
while (Date.now()-hbStart < 600 && !hbData) {
for (let i=0;i<h1._m.length;i++){ const r=h1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ h1._m.splice(i,1); hbData=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(hbData && hbData.desynced === true, 'desynced=true relayed in heartbeat');
h1._m.length = h2._m.length = 0;
// Guest cannot change the control mode -> host must NOT receive a broadcast.
// The rejected sender gets a unicast of the *actual* state so any optimistic
// UI reverts (H-5); assert both halves.
s(h2,'set_control_mode',{controlMode:'everyone'});
let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; }
assert.ok(guestSetBlocked, 'non-host cannot set control mode');
let rejectSync = null; const rsStart = Date.now();
while (Date.now()-rsStart < 600 && !rejectSync) {
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); rejectSync=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(rejectSync && rejectSync.controlMode==='host-only' && rejectSync.hostPeerId==='host1',
'rejected sender is re-synced to actual state');
h1._m.length = h2._m.length = 0;
// Host leaves -> room falls back to 'everyone' and reassigns host to the guest
s(h1,'leave_room',{});
@@ -92,6 +118,30 @@ try {
assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host');
close();
// --- M-4: rapid control-mode toggles are debounced per-room ---
const drid = 'debounce-'+Date.now();
const db1 = await c(), db2 = await c();
await j(db1, drid, 'dhost'); await j(db2, drid, 'dguest'); db1._m.length = db2._m.length = 0;
// First toggle (everyone → host-only) goes through.
s(db1,'set_control_mode',{controlMode:'host-only'});
await w(db1,'control_mode'); await w(db2,'control_mode');
db1._m.length = db2._m.length = 0;
// Immediate second toggle (host-only → everyone) should be debounced:
// broadcast goes to neither peer, but sender gets a re-sync unicast.
s(db1,'set_control_mode',{controlMode:'everyone'});
let dGuestGotIt = false; try { await w(db2,'control_mode',600); } catch { dGuestGotIt = true; }
assert.ok(dGuestGotIt, 'rapid control-mode toggle is debounced (no broadcast)');
let dSenderResync = null; const dsStart = Date.now();
while (Date.now()-dsStart < 600 && !dSenderResync) {
for (let i=0;i<db1._m.length;i++){ const r=db1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ db1._m.splice(i,1); dSenderResync=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(dSenderResync && dSenderResync.controlMode==='host-only',
'debounced toggle re-syncs sender to actual state');
close();
// --- Password room ---
const prid = 'pw-'+Date.now();
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');