144 lines
9.4 KiB
JavaScript
144 lines
9.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import vm from "node:vm";
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
const workerSource = await readFile(new URL("../public/sw.js", import.meta.url), "utf8");
|
|
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", otherSource = "550caef2-b4bc-482c-bd9f-58045ed0a8a8";
|
|
const payload = (cursor = "5", extra = {}) => ({ version: 1, kind: "message", sourceId, messageId: `outbound:${cursor}`, cursor,
|
|
title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}`, ...extra });
|
|
function idb(records) {
|
|
const request = value => { const result = {}; queueMicrotask(() => { result.result = structuredClone(value); result.onsuccess?.(); }); return result; };
|
|
return { open() {
|
|
const opening = {};
|
|
queueMicrotask(() => {
|
|
opening.result = { close() {}, transaction() {
|
|
const transaction = { objectStore: () => ({
|
|
get: key => request(records.get(key)), getAll: () => request([...records.values()]),
|
|
put: value => records.set(value.sourceId, structuredClone(value)), delete: key => records.delete(key),
|
|
}) };
|
|
setImmediate(() => transaction.oncomplete?.()); return transaction;
|
|
} };
|
|
opening.onsuccess?.();
|
|
}); return opening;
|
|
} };
|
|
}
|
|
function fixture(sharedRecords = new Map()) {
|
|
const handlers = new Map(), shown = [], notifications = [], routes = [], messages = [], fetches = [];
|
|
const normalClient = { id: "client", type: "window", url: "https://sybil.example.test/?chat=somewhere", postMessage: value => messages.push(value),
|
|
navigate: async url => { routes.push(["navigate", url]); return normalClient; }, focus: async () => { routes.push(["focus"]); return normalClient; } };
|
|
let windows = [normalClient];
|
|
const makeNotification = value => {
|
|
const notification = { tag: value.tag, data: value.data, closed: false, close() { notification.closed = true; } };
|
|
notifications.push(notification); return notification;
|
|
};
|
|
const self = {
|
|
addEventListener: (name, handler) => handlers.set(name, handler), skipWaiting() {}, location: { origin: "https://sybil.example.test" },
|
|
clients: { claim: async () => undefined, matchAll: async () => windows,
|
|
get: async id => windows.find(client => client.id === id), openWindow: async url => { routes.push(["open", url]); } },
|
|
registration: {
|
|
showNotification: async (title, options) => {
|
|
shown.push({ title, ...structuredClone(options) });
|
|
for (const notification of notifications) if (notification.tag === options.tag) notification.close();
|
|
makeNotification(options);
|
|
},
|
|
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
|
|
},
|
|
};
|
|
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt, TextEncoder,
|
|
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
|
|
const emit = async (type, properties = {}) => {
|
|
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
|
|
};
|
|
return { shown, notifications, routes, messages, fetches, records: sharedRecords, makeNotification,
|
|
push: value => emit("push", { data: { text: () => typeof value === "string" ? value : JSON.stringify(value) } }),
|
|
click: notification => emit("notificationclick", { notification }),
|
|
message: (data, id = "client") => emit("message", { data, source: { id }, ports: [] }),
|
|
emit, windows: value => { windows = value; }, normalClient,
|
|
};
|
|
}
|
|
|
|
test("actual worker push handler displays supplied public message text and never fetches credentials", async () => {
|
|
const f = fixture(); await f.push(payload("5", { body: "Your report is ready. 日本語 🌧️" }));
|
|
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "Your report is ready. 日本語 🌧️");
|
|
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
|
|
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
|
|
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
|
|
});
|
|
|
|
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
|
|
const records = new Map(), f = fixture(records);
|
|
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
|
|
await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("5", { body: "Older reply" }));
|
|
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
|
|
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
|
|
assert.ok(f.shown.every(notification => notification.body === "Latest reply"));
|
|
const restarted = fixture(records); await restarted.push(payload("6"));
|
|
assert.equal(restarted.shown[0].data.cursor, "8");
|
|
assert.equal(restarted.shown[0].body, "Latest reply");
|
|
assert.equal(records.get(sourceId).readCursor, "10");
|
|
});
|
|
|
|
test("malformed/unexpected payloads cannot supply invalid bodies, destinations, cross-source tags or silent read pushes", async () => {
|
|
const f = fixture();
|
|
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: { text: "invalid" } }),
|
|
payload("1", { body: " " }), payload("1", { body: "界".repeat(1001) }), payload("1", { body: "x".repeat(3001) }),
|
|
payload("1", { body: "界".repeat(900), extra: "界".repeat(500) }),
|
|
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
|
|
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
|
|
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
|
|
});
|
|
|
|
test("worker accepts bounded text, renders HTML literally and remains compatible with old generic pushes and stored identities", async () => {
|
|
const f = fixture();
|
|
for (const body of ["界".repeat(1000), "🌧".repeat(750), '<b>public literal text</b>', "You have a new message."]) {
|
|
await f.push(payload("9", { body })); assert.equal(f.shown.at(-1).body, body);
|
|
}
|
|
const records = new Map([[sourceId, { sourceId, latest: payload("10") }]]);
|
|
const upgraded = fixture(records); await upgraded.push(payload("8", { body: "Earlier reply" }));
|
|
assert.equal(upgraded.shown[0].data.cursor, "10"); assert.equal(upgraded.shown[0].body, "You have a new message.");
|
|
});
|
|
|
|
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
|
|
const f = fixture();
|
|
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });
|
|
const newer = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("8") });
|
|
const different = f.makeNotification({ tag: `sybil-agent:${otherSource}`, data: payload("1", { sourceId: otherSource }) });
|
|
const unrelated = f.makeNotification({ tag: "calendar", data: payload("1") });
|
|
const read = { type: "sybil-agent-read", version: 1, sourceId, cursor: "5" };
|
|
await f.message(read, "unknown-client"); assert.equal(older.closed, false);
|
|
f.windows([{ ...f.normalClient, id: "foreign", url: "https://evil.example/" }]);
|
|
await f.message(read, "foreign"); assert.equal(older.closed, false);
|
|
f.windows([f.normalClient]); await f.message(read); await f.message({ ...read, cursor: "2" });
|
|
assert.equal(older.closed, true); assert.equal(newer.closed, false); assert.equal(different.closed, false); assert.equal(unrelated.closed, false);
|
|
assert.equal(f.records.get(sourceId).readCursor, "5");
|
|
await f.message({ ...read, cursor: "8" }); assert.equal(newer.closed, true);
|
|
});
|
|
|
|
test("notification click closes and navigates a same-origin client using fixed root URL, without marking read", async () => {
|
|
const f = fixture(); await f.push(payload());
|
|
const notification = f.notifications[0]; notification.data.url = "https://evil.example/";
|
|
await f.click(notification);
|
|
assert.equal(notification.closed, true);
|
|
assert.deepEqual(f.routes, [["navigate", "https://sybil.example.test/?agent=root"], ["focus"]]);
|
|
assert.equal(f.records.get(sourceId).readCursor, undefined);
|
|
f.windows([{ ...f.normalClient, url: "https://evil.example/" }]); await f.click(notification);
|
|
assert.deepEqual(f.routes.at(-1), ["open", "https://sybil.example.test/?agent=root"]);
|
|
});
|
|
|
|
test("logout closes only owned alerts and subscription change asks pages to re-register without network or secrets", async () => {
|
|
const f = fixture(); await f.push(payload());
|
|
const unrelated = f.makeNotification({ tag: "another-app", data: payload() });
|
|
await f.message({ type: "sybil-agent-logout", version: 1 });
|
|
assert.equal(f.notifications[0].closed, true); assert.equal(unrelated.closed, false);
|
|
await f.emit("pushsubscriptionchange");
|
|
assert.equal(f.messages.at(-1).type, "sybil-push-subscription-changed");
|
|
assert.deepEqual(f.fetches, []);
|
|
});
|
|
|
|
test("worker bounds durable source metadata", async () => {
|
|
const f = fixture();
|
|
for (let index = 0; index < 20; index++) await f.message({ type: "sybil-agent-read", version: 1, sourceId: `4599d348-e053-4937-987d-${String(index).padStart(12, "0")}`, cursor: "1" });
|
|
assert.equal(f.records.size, 16);
|
|
});
|