mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user