707 lines
38 KiB
JavaScript
707 lines
38 KiB
JavaScript
"use strict";
|
|
const {test, afterEach} = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
const source = fs.readFileSync(process.env.BACKGROUND_SOURCE || path.join(__dirname, "../../firefox-extension/session-v2/background.js"), "utf8");
|
|
const A = "11111111-1111-4111-8111-111111111111";
|
|
const B = "22222222-2222-4222-8222-222222222222";
|
|
const KEY = "labfy.session.window_uuid";
|
|
const fixtures = [];
|
|
afterEach(() => {
|
|
// Assertions thrown inside extension callbacks may be deliberately caught.
|
|
// Record forbidden reads as well, so those catches cannot hide a privacy regression.
|
|
for (const f of fixtures.splice(0)) assert.deepEqual(f.forbiddenReads, []);
|
|
});
|
|
const plain = value => JSON.parse(JSON.stringify(value));
|
|
const flush = async () => { for (let i = 0; i < 240; ++i) await Promise.resolve(); };
|
|
function event() {
|
|
const listeners = [];
|
|
return {addListener: fn => listeners.push(fn), emit: (...args) => listeners.map(fn => fn(...args))};
|
|
}
|
|
async function fixture(options = {}) {
|
|
let now = 0, sequence = 0, random = 0;
|
|
const timers = new Map(), values = options.values || new Map();
|
|
const state = {ids: options.ids || [1], values, forbiddenReads: [], writes: [], maps: [], updates: [], ports: [], reads: [], mode: "ok", creates: 0, read: options.read, write: options.write, enumerate: options.enumerate};
|
|
state.bootstrap = options.bootstrap; state.bootstraps = [];
|
|
state.classifications = []; state.kinds = options.kinds || new Map();
|
|
state.conIds = options.conIds || new Map();
|
|
fixtures.push(state);
|
|
const schedule = (fn, ms, interval = 0) => { const id = ++sequence; timers.set(id, {at: now + ms, fn, interval}); return id; };
|
|
// Poisoned properties make any navigation/content inspection fail the test.
|
|
function window(id) {
|
|
const w = {id, type: "normal"};
|
|
for (const name of ["title", "url", "tabs", "width", "height"]) Object.defineProperty(w, name, {get() { state.forbiddenReads.push(name); assert.fail(`Forbidden window property: ${name}`); }});
|
|
return w;
|
|
}
|
|
let nextWindow = Math.max(0, ...state.ids);
|
|
const onMessage = event();
|
|
const runtime = {
|
|
id: "session-v2@labfy.org", onMessage, onInstalled: event(),
|
|
getURL: name => `moz-extension://synthetic/${name}`,
|
|
connectNative(name) {
|
|
assert.equal(name, "org.labfy.session_v2_identity");
|
|
const p = {onMessage: event(), onDisconnect: event(), disconnected: false,
|
|
disconnect() { p.disconnected = true; p.onDisconnect.emit(); },
|
|
postMessage(message) {
|
|
assert.equal(p.disconnected, false);
|
|
const m = plain(message);
|
|
if (m.op === "classify-window") {
|
|
assert.deepEqual(Object.keys(m).sort(), ["op", "runtime_token"]);
|
|
const marked = state.updates.findLast(u => u.titlePreface === `[LABFY:${m.runtime_token}] `);
|
|
assert.ok(marked, "classification must correlate its own prefix");
|
|
state.classifications.push({id: marked.id, ...m});
|
|
if (state.classifyHook) return state.classifyHook(p, m, marked.id);
|
|
const kind = state.kinds.get(marked.id) || "browser";
|
|
if (!["browser", "managed-webapp"].includes(kind)) p.onMessage.emit({result: "WINDOW_CLASS_UNSUPPORTED"});
|
|
else p.onMessage.emit({result: "OK", kind, runtime_token: m.runtime_token,
|
|
con_id: state.conIds.get(marked.id) ?? 1000 + marked.id});
|
|
return;
|
|
}
|
|
if (m.op === "bootstrap-eligibility") {
|
|
assert.deepEqual(m, {op: "bootstrap-eligibility"});
|
|
state.bootstraps.push({port: state.ports.indexOf(p)});
|
|
if (state.bootstrapHook) return state.bootstrapHook(p);
|
|
if (state.bootstrap === "throw") throw new Error("bootstrap post failure");
|
|
if (state.bootstrap === "disconnect") { p.disconnect(); return; }
|
|
if (state.bootstrap !== "hold") p.onMessage.emit(state.bootstrap === "invalid" ? {result: "OK", baseline_enrollment_allowed: "true"} :
|
|
{result: "OK", baseline_enrollment_allowed: state.bootstrap === true});
|
|
return;
|
|
}
|
|
assert.deepEqual(Object.keys(m).sort(), ["op", "runtime_token", "uuid"]);
|
|
state.maps.push({port: state.ports.indexOf(p), ...m});
|
|
if (state.mode === "throw") throw new Error("synthetic post failure");
|
|
if (state.mode === "disconnect") { p.disconnect(); return; }
|
|
if (state.mode === "ok") {
|
|
const marked = state.updates.findLast(u => u.titlePreface === `[LABFY:${m.runtime_token}] `);
|
|
assert.ok(marked, "mapping must correlate its own prefix");
|
|
if ((state.kinds.get(marked.id) || "browser") !== "browser") p.onMessage.emit({result: "CORRELATION_TIMEOUT"});
|
|
else p.onMessage.emit({result: "OK", uuid: m.uuid, con_id: state.conIds.get(marked.id) ?? 1000 + marked.id});
|
|
}
|
|
}};
|
|
state.ports.push(p);
|
|
return p;
|
|
},
|
|
};
|
|
const browser = {runtime, windows: {
|
|
onCreated: event(), onRemoved: event(),
|
|
create: async query => {
|
|
assert.deepEqual(plain(query), {type: "normal"}); ++state.creates;
|
|
const id = ++nextWindow;
|
|
state.ids.push(id);
|
|
return window(id);
|
|
},
|
|
getAll: async query => { if (state.enumerate) state.enumerate(state); assert.deepEqual(plain(query), {populate: false, windowTypes: ["normal"]}); return state.ids.map(window); },
|
|
update: async (id, change) => { assert.deepEqual(Object.keys(change), ["titlePreface"]); state.updates.push({id, ...plain(change)}); if (state.update) return state.update(id, change); },
|
|
}, sessions: {
|
|
onChanged: event(),
|
|
getWindowValue: async (id, key) => {
|
|
assert.equal(key, KEY); state.reads.push({id, at: now});
|
|
return state.read ? state.read(id, now) : values.get(id);
|
|
},
|
|
setWindowValue: async (id, key, value) => {
|
|
assert.equal(key, KEY); state.writes.push({id, value, at: now});
|
|
if (state.write) return state.write(id, value);
|
|
values.set(id, value);
|
|
},
|
|
getTabValue: () => { state.forbiddenReads.push("getTabValue"); assert.fail("Tab fallback forbidden"); },
|
|
setTabValue: () => { state.forbiddenReads.push("setTabValue"); assert.fail("Tab witness writes forbidden"); },
|
|
}, tabs: new Proxy({}, {get() { state.forbiddenReads.push("tabs API"); assert.fail("Tab content API forbidden"); }})};
|
|
vm.runInNewContext(source, {browser, performance: {now: () => now}, crypto: {randomUUID: () => options.random ? options.random(++random) : `99999999-9999-4999-8999-${String(++random).padStart(12, "0")}`},
|
|
setTimeout: (fn, ms) => schedule(fn, ms), clearTimeout: id => timers.delete(id), setInterval: (fn, ms) => schedule(fn, ms, ms)});
|
|
state.tick = async ms => {
|
|
const end = now + ms;
|
|
await flush();
|
|
while (true) {
|
|
const next = [...timers].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
|
if (!next) break;
|
|
const [id, t] = next; now = t.at; timers.delete(id);
|
|
if (t.interval) timers.set(id, {...t, at: now + t.interval});
|
|
t.fn(); await flush();
|
|
}
|
|
now = end; await flush();
|
|
};
|
|
state.open = (id = ++nextWindow, extra = {}) => {
|
|
if (!state.ids.includes(id)) state.ids.push(id);
|
|
const w = window(id);
|
|
Object.assign(w, extra);
|
|
browser.windows.onCreated.emit(w);
|
|
return id;
|
|
};
|
|
state.close = id => { state.ids = state.ids.filter(value => value !== id); browser.windows.onRemoved.emit(id); };
|
|
state.changed = () => browser.sessions.onChanged.emit();
|
|
state.install = (reason = "install") => runtime.onInstalled.emit({reason});
|
|
state.status = async () => plain(await onMessage.emit({op: "identity-status"}, {id: runtime.id})[0]);
|
|
state.rawMessage = (message, sender) => onMessage.emit(message, sender)[0];
|
|
state.capture = async () => {
|
|
const results = state.ports.at(-1).onMessage.emit({event: "capture"});
|
|
await flush();
|
|
state.captureResult = results[0];
|
|
};
|
|
if (options.install) state.install();
|
|
await flush();
|
|
return state;
|
|
}
|
|
|
|
|
|
const ready = async f => { await f.tick(33000); assert.equal((await f.status()).phase, "READY"); };
|
|
const capture = async f => { await f.capture(); await f.tick(2000); return plain(await f.captureResult); };
|
|
// Assertion ancienne remplacée : install seul n'est pas une preuve d'absence de checkpoint.
|
|
test("installation sans autorité backend : baseline bloquée même si install arrive après start", async () => {
|
|
const f = await fixture({ids: [1, 2]});
|
|
await f.tick(10000); f.install(); await ready(f);
|
|
assert.equal(f.writes.length, 0);
|
|
assert.equal((await capture(f)).result, "REFUSED");
|
|
assert.equal(f.creates, 0);
|
|
});
|
|
test("Ctrl+N après prêt et longtemps après settle : automatique unique et capturable", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(); await f.tick(999); assert.equal(f.writes.length, 0);
|
|
await f.tick(3001); assert.equal(f.writes.length, 1);
|
|
await f.tick(120000); f.open(); await f.tick(4000);
|
|
assert.equal(f.writes.length, 2); assert.equal(new Set(f.writes.map(w => w.value)).size, 2);
|
|
assert.equal((await capture(f)).result, "OK"); assert.equal(f.maps.length, 3);
|
|
});
|
|
test("startup restore undefined au-delà du boot : aucune écriture puis valeur tardive préservée", async () => {
|
|
const f = await fixture(); await f.tick(70000);
|
|
assert.equal((await f.status()).phase, "READY");
|
|
assert.equal((await f.status()).result, "REFUSED");
|
|
f.values.set(1, A); assert.equal((await capture(f)).result, "OK");
|
|
f.open(); await f.tick(10000); assert.equal(f.writes.length, 1);
|
|
});
|
|
test("onCreated durant startup : UUID reçu avant stabilisation préservé", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await f.tick(10000); f.open(2);
|
|
await f.tick(15000); assert.equal(f.writes.length, 0);
|
|
f.values.set(2, B); await f.tick(12000); assert.equal((await f.status()).phase, "READY");
|
|
f.open(3); await f.tick(4000); assert.deepEqual(f.writes.map(w => w.id), [3]);
|
|
});
|
|
test("installation baseline bloquée ; valeur du candidat boot reçue avant stabilisation préservée", async () => {
|
|
const f = await fixture({install: true}); f.open(2); await f.tick(25000);
|
|
assert.equal(f.writes.length, 0); f.values.set(2, B); await f.tick(14000);
|
|
assert.deepEqual(f.writes.map(w => w.id), []); assert.equal(f.values.get(2), B);
|
|
});
|
|
test("deux fenêtres mêmes contenus, créations simultanées : deux identités distinctes", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); f.open(3); await f.tick(4000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
assert.equal(new Set(f.writes.map(w => w.value)).size, 2);
|
|
});
|
|
test("signal onChanged : UUID restauré reçu pendant guard préservé", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.changed(); f.open(2); await f.tick(4000);
|
|
assert.equal(f.writes.length, 0); f.values.set(2, B); await f.tick(6000);
|
|
assert.equal((await capture(f)).result, "OK"); assert.equal(f.values.get(2), B);
|
|
});
|
|
test("onChanged pendant observation : passage révoqué puis candidat réobservé", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(1500); f.changed(); await f.tick(15000);
|
|
assert.equal(f.writes.length, 1); assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("sessionId positif protège ; absence de sessionId ne prouve rien", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2, {sessionId: "closed-session"}); await f.tick(10000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("restauration tardive non signalée avec UUID pendant observation conservé", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(1500); f.values.set(2, B); await f.tick(5000);
|
|
assert.equal(f.writes.length, 0); assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("limite irréductible : restauration sans signal ni extData ressemble à Ctrl+N", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(4000);
|
|
// Ce test expose le compromis, pas une preuve de classification parfaite.
|
|
assert.equal(f.writes.length, 1);
|
|
f.values.set(2, B); assert.equal((await capture(f)).result, "REFUSED");
|
|
assert.equal(f.writes.length, 1); assert.equal(f.values.get(2), B);
|
|
});
|
|
test("API rejection sur candidat : refus sans retry, capture peut lire valeur tardive", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.read = id => { if (id === 2) throw new Error("synthetic rejection"); return A; };
|
|
f.open(2); await f.tick(5000); assert.equal(f.writes.length, 0);
|
|
f.read = id => id === 1 ? A : undefined; await f.tick(5000); assert.equal(f.writes.length, 0);
|
|
f.values.set(2, B); f.read = null; assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("provider reconnect, captures concurrentes et ancien port ne changent aucune identité", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); f.mode = "hold";
|
|
await f.capture(); await f.capture(); assert.equal(f.maps.length, 1);
|
|
const old = f.ports[0]; old.disconnect(); await flush();
|
|
f.mode = "ok"; await f.tick(1000); assert.equal((await capture(f)).result, "OK");
|
|
old.onMessage.emit({result: "OK", uuid: B, con_id: 999});
|
|
assert.equal(f.writes.length, 0); assert.equal(f.updates.at(-1).titlePreface, "");
|
|
});
|
|
test("timeout natif et post failure restent récupérables sans vieux timer destructeur", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); f.mode = "hold";
|
|
await f.capture(); await f.tick(5000); assert.equal(f.ports[0].disconnected, true);
|
|
await f.tick(1000); f.mode = "throw"; await f.capture(); await flush();
|
|
await f.tick(1000); f.mode = "ok"; assert.equal((await capture(f)).result, "OK");
|
|
assert.equal(f.ports.at(-1).disconnected, false);
|
|
});
|
|
test("collision persistée refuse lot entier et collision générée refuse le mint", async () => {
|
|
const f = await fixture({ids: [1, 2], values: new Map([[1, A], [2, A]])});
|
|
assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.maps.length, 0);
|
|
const g = await fixture({values: new Map([[1, A]]), random: () => A}); await ready(g);
|
|
g.open(2); await g.tick(4000); assert.equal(g.writes.length, 0);
|
|
});
|
|
test("tab move, ordre et fenêtre restante ne récupèrent aucune identité", async () => {
|
|
const f = await fixture({ids: [9, 2], values: new Map([[9, A], [2, B]])});
|
|
assert.equal((await capture(f)).result, "OK"); f.ids.reverse();
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.deepEqual(f.maps.map(m => m.uuid), [B, A, B, A]);
|
|
f.values.delete(2); f.close(9); assert.equal((await capture(f)).result, "REFUSED");
|
|
await f.tick(35000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("update/restart : jamais enrôler baseline absente, mêmes valeurs autoritaires", async () => {
|
|
for (const reason of ["update", "browser_update", undefined]) {
|
|
const f = await fixture(); if (reason) f.install(reason);
|
|
await f.tick(70000); assert.equal(f.writes.length, 0);
|
|
}
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f); f.open(2); await f.tick(4000);
|
|
const g = await fixture({ids: [8], values: new Map([[8, f.values.get(2)]])});
|
|
assert.equal((await capture(g)).result, "OK"); assert.equal(g.writes.length, 0);
|
|
});
|
|
test("window close avant mint : seul le candidat remplaçant est réobservé", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(1000); f.close(2); f.open(3); await f.tick(10000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [3]);
|
|
});
|
|
test("écriture appliquée mais rejetée : pas de retry ni capture concurrente partielle", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.write = (id, value) => { f.values.set(id, value); throw new Error("uncertain"); };
|
|
f.open(2); await f.tick(1000); await f.capture(); assert.equal((await f.captureResult).result, "BUSY");
|
|
await f.tick(8000); assert.equal(f.writes.length, 1);
|
|
assert.equal((await capture(f)).result, "OK"); assert.equal(f.writes.length, 1);
|
|
});
|
|
test("API suspendue : arrêt définitif, pas d'accumulation d'appels", async () => {
|
|
const f = await fixture({read: () => new Promise(() => {})});
|
|
await f.capture(); await f.tick(40000); const count = f.reads.length;
|
|
await f.capture(); await f.tick(10000);
|
|
assert.equal(f.reads.length, count); assert.equal(f.writes.length, 0);
|
|
assert.equal((await f.status()).result, "REFUSED");
|
|
});
|
|
test("valeurs invalides et limites locales refusent sans fausse réussite", async () => {
|
|
for (const invalid of [null, "", "wrong", 42, {uuid: A}]) {
|
|
const f = await fixture({values: new Map([[1, invalid]]), install: true});
|
|
await f.tick(35000); assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.writes.length, 0);
|
|
}
|
|
const f = await fixture({ids: Array.from({length: 257}, (_, i) => i)});
|
|
assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.reads.length, 0);
|
|
});
|
|
test("capture seule reste non-mutante, absence tardive récupérée hors délai", async () => {
|
|
const f = await fixture({read: (_, now) => now >= 7000 ? A : undefined});
|
|
assert.equal((await capture(f)).result, "REFUSED"); await f.tick(6000);
|
|
assert.equal((await capture(f)).result, "OK"); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("une lecture rejetée ne libère pas l'opération tant que sa sœur est pendante", async () => {
|
|
const f = await fixture({ids: [1, 2], read: id => {
|
|
if (id === 1) throw new Error("reject"); return new Promise(() => {});
|
|
}});
|
|
await f.capture(); const count = f.reads.length;
|
|
await f.capture(); assert.equal(f.reads.length, count);
|
|
await f.tick(10000); assert.equal(f.reads.length, count); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("valeur tardive à la dernière relecture avant mint est préservée", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
let finalReads = 0;
|
|
f.read = (id, now) => id === 1 ? A : now >= 36000 && ++finalReads >= 2 ? B : undefined;
|
|
f.open(2); await f.tick(5000);
|
|
assert.equal(f.writes.length, 0); assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("immuabilité entre opérations : changement UUID refuse publication entière", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); assert.equal((await capture(f)).result, "OK");
|
|
f.values.set(1, B); const before = f.maps.length;
|
|
assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.maps.length, before);
|
|
await f.tick(35000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("API suspendue pendant écriture : aucun retry, aucune capture d'identité partielle", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
let release;
|
|
f.write = () => new Promise(resolve => { release = resolve; });
|
|
f.open(2); await f.tick(6000); assert.equal(f.writes.length, 1);
|
|
assert.equal((await capture(f)).result, "REFUSED");
|
|
f.values.set(2, f.writes[0].value); release(); await f.tick(10000);
|
|
assert.equal(f.writes.length, 1); assert.equal((await capture(f)).result, "REFUSED");
|
|
});
|
|
test("restart pendant observation perd toute autorisation de mint", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f); f.open(2); await f.tick(1000);
|
|
const g = await fixture({ids: [...f.ids], values: f.values}); await g.tick(70000);
|
|
assert.equal(g.writes.length, 0); assert.equal((await capture(g)).result, "REFUSED");
|
|
});
|
|
test("rejeter une lecture baseline au premier install la protège définitivement", async () => {
|
|
const f = await fixture({install: true, read: () => { throw new Error("read rejected"); }});
|
|
await f.tick(2000); f.read = null; await f.tick(40000);
|
|
assert.equal(f.writes.length, 0); assert.equal((await capture(f)).result, "REFUSED");
|
|
});
|
|
test("fenêtre sessionId ambiguë bloque capture mais pas mint du Ctrl+N suivant", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.changed(); f.open(2, {sessionId: "restored"}); await f.tick(6000); f.open(3); await f.tick(10000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [3]); assert.equal((await f.status()).result, "REFUSED");
|
|
});
|
|
test("capture native avant fin d'installation n'attribue aucune identité", async () => {
|
|
const f = await fixture({install: true});
|
|
assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.writes.length, 0);
|
|
await f.tick(31000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("message historique create-window supprimé : aucune création et aucune attribution", async () => {
|
|
const f = await fixture();
|
|
assert.equal(f.rawMessage({op: "create-window"}, {id: "session-v2@labfy.org"}), undefined);
|
|
assert.equal(f.creates, 0); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("vérification divergente après écriture refuse aussi les publications suivantes", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.write = id => { f.values.set(id, B); };
|
|
f.open(2); await f.tick(5000); assert.equal(f.writes.length, 1);
|
|
assert.equal((await capture(f)).result, "REFUSED");
|
|
assert.equal(f.maps.length, 0); assert.equal(f.values.get(2), B);
|
|
await f.tick(5000); assert.equal(f.writes.length, 1);
|
|
});
|
|
test("listener synchrone : onCreated pendant le premier getAll reste en quarantaine", async () => {
|
|
const f = await fixture({install: true, enumerate: state => {
|
|
state.enumerate = null; state.open(2);
|
|
}});
|
|
await f.tick(35000); assert.equal(f.writes.length, 0);
|
|
f.values.set(2, B); await f.tick(5000);
|
|
assert.deepEqual(f.writes.map(w => w.id), []); assert.equal(f.values.get(2), B);
|
|
});
|
|
test("une preuve lue dans un lot partiellement rejeté ne peut devenir un mint", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2);
|
|
f.read = id => { if (id === 1) throw new Error("sibling rejection"); return B; };
|
|
await f.capture(); assert.equal((await f.captureResult).result, "REFUSED");
|
|
f.read = null; await f.tick(10000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("une valeur invalide observée puis disparue ne permet jamais de mint", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); f.values.set(2, "corrupt");
|
|
await f.capture(); assert.equal((await f.captureResult).result, "REFUSED");
|
|
f.values.delete(2); await f.tick(10000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("rejets non Error : refus explicite, sans exception dans le gestionnaire d'erreur", async () => {
|
|
for (const rejected of [null, "read failed"]) {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.read = () => Promise.reject(rejected); f.open(2); await f.tick(2000);
|
|
const status = await f.status(); assert.equal(status.result, "REFUSED"); assert.equal(status.reason, "API_REJECTED");
|
|
assert.equal((await capture(f)).result, "REFUSED"); assert.equal(f.writes.length, 0);
|
|
}
|
|
});
|
|
|
|
// Audit corrective : attentes produit, exécutables aussi contre la source pré-patch.
|
|
test("audit BOOT : Ctrl+N à 10 secondes puis première capture automatique", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
await f.tick(10000); f.open(2); await f.tick(25000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("audit historique absent : ne bloque pas les futurs Ctrl+N même après 60s", async () => {
|
|
const f = await fixture(); f.install("update"); await f.tick(70000);
|
|
assert.equal((await f.status()).result, "REFUSED");
|
|
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
assert.equal((await capture(f)).result, "REFUSED");
|
|
f.close(1); f.open(3); await f.tick(6000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("audit fermeture onglet : observation retentée puis Ctrl+N suivant", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(1500); f.changed(); await f.tick(12000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
f.open(3); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
});
|
|
test("audit deux Ctrl+N espacés de 1s : mêmes candidats retentés", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2); await f.tick(1000); f.open(3); await f.tick(8000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
test("audit type facultatif : getAll normal confirme le candidat", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2, {type: undefined}); await f.tick(6000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
});
|
|
test("audit réinstallation avec checkpoint passé : install seul ne permet aucun mint baseline", async () => {
|
|
// Le checkpoint externe est invisible à cette VM comme au background ;
|
|
// aucune autorité backend d'absence n'a été reçue.
|
|
const f = await fixture({install: true}); await f.tick(70000);
|
|
assert.equal(f.writes.length, 0);
|
|
assert.equal((await f.status()).result, "REFUSED");
|
|
});
|
|
|
|
test("timeout titlePreface : nettoyage unique après règlement tardif sans relancer le marquage", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
let release;
|
|
f.update = (_, change) => change.titlePreface ? new Promise(resolve => { release = resolve; }) : undefined;
|
|
await f.capture(); await f.tick(3000);
|
|
assert.equal((await f.captureResult).result, "REFUSED");
|
|
assert.equal(f.updates.filter(u => u.titlePreface).length, 1);
|
|
const before = f.updates.length;
|
|
release(); await f.tick(10000);
|
|
assert.equal(f.updates.length, before + 1);
|
|
assert.equal(f.updates.at(-1).titlePreface, "");
|
|
assert.equal((await f.status()).result, "REFUSED");
|
|
assert.equal((await f.status()).prefixCleanupPending, false);
|
|
await f.tick(10000); assert.equal(f.updates.length, before + 1);
|
|
});
|
|
test("popup explicite et type absent hors getAll normal : aucun mint", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.open(2, {type: "popup"}); f.ids = [1];
|
|
f.open(3, {type: undefined}); f.ids = [1];
|
|
await f.tick(10000); assert.equal(f.writes.length, 0);
|
|
});
|
|
test("update : UUID baseline reçu tardivement est lu sans réécriture", async () => {
|
|
const f = await fixture(); f.install("update"); await f.tick(70000);
|
|
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
f.values.set(1, A); await f.tick(3000);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
assert.equal((await f.status()).result, "READY");
|
|
});
|
|
|
|
test("activité après écriture : ne retente pas l'écriture, conserve les autres candidats", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.write = (id, value) => {
|
|
f.values.set(id, value);
|
|
if (id === 2) f.open(3);
|
|
};
|
|
f.open(2); await f.tick(10000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
|
|
// P0 : doit échouer contre le background pré-protocole conservé en preuve.
|
|
test("P0 premier install avec absence attestée : deux UUID stables sans clic", async () => {
|
|
for (const early of [true, false]) {
|
|
const f = await fixture({ids: [1, 2], bootstrap: true, install: early});
|
|
if (!early) { await f.tick(35000); f.install(); }
|
|
await f.tick(45000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [1, 2]);
|
|
assert.equal(new Set(f.writes.map(w => w.value)).size, 2);
|
|
assert.equal(f.bootstraps.length, 2); // Recheck avant chacune des écritures.
|
|
await f.tick(60000); assert.equal(f.writes.length, 2);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
}
|
|
});
|
|
|
|
test("bootstrap refus/timeout/erreur : baseline isolée, Ctrl+N BOOT et futur automatiques", async () => {
|
|
for (const bootstrap of [false, "hold", "throw", "disconnect", "invalid"]) {
|
|
const f = await fixture({install: true, bootstrap});
|
|
await f.tick(10000); f.open(2);
|
|
await f.tick(40000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2], String(bootstrap));
|
|
assert.equal(f.bootstraps.length, 1);
|
|
f.bootstrap = true; await f.tick(20000); // La reconnexion n'est pas une nouvelle autorité.
|
|
f.open(3); await f.tick(6000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [2, 3]);
|
|
assert.equal(f.bootstraps.length, 1);
|
|
assert.equal(f.values.has(1), false);
|
|
}
|
|
});
|
|
|
|
test("bootstrap : checkpoint créé entre deux écritures refuse la seconde", async () => {
|
|
const f = await fixture({ids: [1, 2], install: true, bootstrap: true});
|
|
f.write = (id, value) => { f.values.set(id, value); f.bootstrap = false; };
|
|
await f.tick(45000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [1]);
|
|
assert.equal(f.bootstraps.length, 2);
|
|
assert.equal(f.values.has(2), false);
|
|
await f.tick(60000); assert.equal(f.bootstraps.length, 2);
|
|
});
|
|
|
|
test("bootstrap en vol : capture coalescée, pas de mélange avec map-window", async () => {
|
|
const f = await fixture({install: true, bootstrap: "hold"});
|
|
await f.tick(33000);
|
|
assert.equal(f.bootstraps.length, 1);
|
|
await f.capture(); assert.equal((await f.captureResult).result, "BUSY");
|
|
assert.equal(f.maps.length, 0);
|
|
f.ports.at(-1).onMessage.emit({result: "OK", baseline_enrollment_allowed: true});
|
|
await f.tick(1000);
|
|
assert.equal(f.writes.length, 1);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.equal(f.maps.length, 1);
|
|
});
|
|
|
|
test("capture en vol retarde bootstrap ; onInstalled ne poste rien en concurrence", async () => {
|
|
const f = await fixture({values: new Map([[1, A]]), bootstrap: true});
|
|
f.mode = "hold"; await f.capture();
|
|
f.install(); await f.tick(3000);
|
|
assert.equal(f.bootstraps.length, 0);
|
|
assert.equal(f.maps.length, 1);
|
|
f.ports.at(-1).onMessage.emit({result: "OK", uuid: A, con_id: 1001});
|
|
f.mode = "ok"; await f.tick(40000);
|
|
assert.equal(f.writes.length, 0); assert.equal(f.bootstraps.length, 0);
|
|
});
|
|
|
|
test("bootstrap réponse positive après onChanged : lot révoqué sans retry baseline", async () => {
|
|
const f = await fixture({install: true, bootstrap: "hold"});
|
|
await f.tick(33000); assert.equal(f.bootstraps.length, 1);
|
|
f.changed(); f.ports.at(-1).onMessage.emit({result: "OK", baseline_enrollment_allowed: true});
|
|
await f.tick(30000); assert.equal(f.writes.length, 0);
|
|
assert.equal(f.bootstraps.length, 1);
|
|
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
});
|
|
|
|
test("bootstrap : UUID arrivé pendant attestation préservé, aucun overwrite", async () => {
|
|
const f = await fixture({install: true, bootstrap: "hold"});
|
|
await f.tick(33000);
|
|
f.values.set(1, A);
|
|
f.ports.at(-1).onMessage.emit({result: "OK", baseline_enrollment_allowed: true});
|
|
await f.tick(20000);
|
|
assert.equal(f.writes.length, 0); assert.equal(f.values.get(1), A);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
|
|
test("bootstrap : rollback UUID connu et écriture incertaine jamais retentés", async () => {
|
|
const f = await fixture({install: true, bootstrap: true});
|
|
f.write = (id, value) => { f.values.set(id, value); throw new Error("uncertain baseline"); };
|
|
await f.tick(45000); assert.equal(f.writes.length, 1);
|
|
f.values.delete(1); await f.tick(20000);
|
|
assert.equal(f.writes.length, 1); assert.equal(f.bootstraps.length, 1);
|
|
assert.equal((await capture(f)).result, "REFUSED");
|
|
});
|
|
|
|
test("bootstrap : restart et update restent sans autorité malgré host positif", async () => {
|
|
for (const reason of [undefined, "update", "browser_update"]) {
|
|
const f = await fixture({bootstrap: true}); if (reason) f.install(reason);
|
|
await f.tick(45000); assert.equal(f.writes.length, 0); assert.equal(f.bootstraps.length, 0);
|
|
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
}
|
|
});
|
|
|
|
test("bootstrap : événement pré-photographie/sessionId protège même avec host positif", async () => {
|
|
const f = await fixture({install: true, bootstrap: true, enumerate: state => {
|
|
state.enumerate = null; state.open(2);
|
|
}});
|
|
f.open(1, {sessionId: "restored"});
|
|
await f.tick(45000); assert.equal(f.writes.length, 0); assert.equal(f.bootstraps.length, 0);
|
|
});
|
|
|
|
test("bootstrap : timeout déconnecte, réponse ancien port ignorée, map reconnecté", async () => {
|
|
const f = await fixture({install: true, bootstrap: "hold"});
|
|
await f.tick(33000); const old = f.ports.at(-1);
|
|
await f.tick(10000); assert.equal(old.disconnected, true);
|
|
old.onMessage.emit({result: "OK", baseline_enrollment_allowed: true});
|
|
f.values.set(1, A); f.bootstrap = true;
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.equal(f.writes.length, 0); assert.equal(f.bootstraps.length, 1);
|
|
assert.notEqual(f.ports.at(-1), old);
|
|
});
|
|
|
|
test("bootstrap : réponses natives non exactes refusées sans écriture baseline", async () => {
|
|
for (const answer of [{result: "REFUSED"}, {result: "OK"},
|
|
{result: "OK", baseline_enrollment_allowed: true, extra: true},
|
|
{result: "OK", baseline_enrollment_allowed: 1}]) {
|
|
const f = await fixture({install: true});
|
|
f.bootstrapHook = p => p.onMessage.emit(answer);
|
|
await f.tick(45000); assert.equal(f.writes.length, 0);
|
|
assert.equal(f.bootstraps.length, 1);
|
|
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
|
|
}
|
|
});
|
|
|
|
|
|
// Régression webapp : son type WebExtension est normal, mais ses extData ne
|
|
// sont pas persistées par Firefox. Seul le host peut attester son exclusion.
|
|
test("webapp premier ID sans UUID : seule la fenêtre navigateur est publiée", async () => {
|
|
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.deepEqual(f.maps.map(m => m.uuid), [B]);
|
|
assert.ok(f.classifications.some(c => c.id === 1));
|
|
assert.ok(f.reads.every(r => r.id === 2));
|
|
assert.deepEqual(f.writes, []);
|
|
});
|
|
|
|
test("webapp exclue de tout mint baseline ou Ctrl+N, navigateur nouveau conservé", async () => {
|
|
const f = await fixture({ids: [1, 2], install: true, bootstrap: true,
|
|
kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
|
|
await ready(f); f.kinds.set(3, "managed-webapp"); f.open(3); f.open(4);
|
|
await f.tick(10000);
|
|
assert.deepEqual(f.writes.map(w => w.id), [4]);
|
|
assert.ok(f.reads.every(r => ![1, 3].includes(r.id)));
|
|
assert.equal(f.values.has(1), false); assert.equal(f.values.has(3), false);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
});
|
|
|
|
test("navigateur normal sans UUID reste refusé même avec une webapp exclue", async () => {
|
|
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]])});
|
|
const result = await capture(f);
|
|
assert.equal(result.result, "REFUSED"); assert.equal(result.reason, "INVALID_UUID");
|
|
assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
|
|
await f.tick(70000);
|
|
assert.equal((await f.status()).result, "REFUSED"); assert.deepEqual(f.writes, []);
|
|
});
|
|
|
|
test("classe inconnue : refus avant lecture, aucun retry périodique de marquage", async () => {
|
|
const f = await fixture({kinds: new Map([[1, "unknown"]]), values: new Map([[1, A]])});
|
|
await f.tick(2000);
|
|
const count = f.classifications.length;
|
|
await f.tick(70000);
|
|
assert.equal(f.classifications.length, count); assert.equal(count, 1);
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_UNAVAILABLE");
|
|
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
|
|
});
|
|
|
|
test("classe ou con_id converti : preuve fraîche refusée durablement jusqu'au close", async () => {
|
|
for (const change of ["kind", "con_id"]) {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
assert.equal((await capture(f)).result, "OK");
|
|
const published = f.maps.length;
|
|
if (change === "kind") f.kinds.set(1, "managed-webapp"); else f.conIds.set(1, 9999);
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
|
|
f.kinds.clear(); f.conIds.clear();
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
|
|
assert.equal(f.maps.length, published); assert.deepEqual(f.writes, []);
|
|
f.close(1); f.open(1); f.values.set(1, A);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
}
|
|
});
|
|
|
|
test("webapp devenue navigateur : ne jamais conserver une exclusion ancienne", async () => {
|
|
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
|
|
assert.equal((await capture(f)).result, "OK");
|
|
f.kinds.set(1, "browser"); f.values.set(1, A);
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
|
|
assert.deepEqual(f.maps.map(m => m.uuid), [B]); assert.deepEqual(f.writes, []);
|
|
});
|
|
|
|
test("ensemble changé pendant classification : aucun UUID lu ni publié", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
f.classifyHook = (p, m, id) => {
|
|
f.open(2); f.classifyHook = null;
|
|
p.onMessage.emit({result: "OK", kind: "browser", runtime_token: m.runtime_token, con_id: 1000 + id});
|
|
};
|
|
await f.capture();
|
|
assert.equal((await f.captureResult).reason, "WINDOW_SET_CHANGED");
|
|
// Before the next independent autoStep, this refused capture read no UUID.
|
|
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
|
|
});
|
|
|
|
test("cache borné : aucun marquage idle après le premier classement", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
await f.tick(2000); const classified = f.classifications.length, updates = f.updates.length;
|
|
await f.tick(90000);
|
|
assert.equal(classified, 1); assert.equal(f.classifications.length, classified);
|
|
assert.equal(f.updates.length, updates); assert.deepEqual(f.maps, []);
|
|
assert.equal((await capture(f)).result, "OK");
|
|
assert.equal(f.classifications.length, classified + 2);
|
|
});
|
|
|
|
test("réponse classification fermée : jeton, classe, con_id et champs exacts", async () => {
|
|
for (const change of [{runtime_token: "0".repeat(32)}, {kind: "skip"}, {con_id: 0}, {con_id: true}, {extra: true}]) {
|
|
const f = await fixture({values: new Map([[1, A]])});
|
|
f.classifyHook = (p, m) => p.onMessage.emit({result: "OK", kind: "browser", runtime_token: m.runtime_token, con_id: 1001, ...change});
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_UNAVAILABLE");
|
|
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
|
|
}
|
|
});
|
|
|
|
|
|
test("conversion juste avant mint : aucune écriture sous une classe périmée", async () => {
|
|
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
|
|
f.classifyHook = (p, m, id) => {
|
|
const count = f.classifications.filter(c => c.id === id).length;
|
|
p.onMessage.emit({result: "OK", kind: id === 2 && count > 1 ? "managed-webapp" : "browser",
|
|
runtime_token: m.runtime_token, con_id: 1000 + id});
|
|
};
|
|
f.open(2); await f.tick(6000);
|
|
assert.deepEqual(f.writes, []); assert.equal(f.values.has(2), false);
|
|
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
|
|
});
|