468 lines
30 KiB
JavaScript
468 lines
30 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { randomUUID } from "node:crypto";
|
|
import {
|
|
AgentPushClient, agentPushAuthorityScope, agentPushCursor, createAgentPresenceReporter, createAgentPushController,
|
|
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
|
} from "../src/lib/agent-push.ts";
|
|
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
|
|
|
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
|
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
|
const configuration = () => ({ version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
|
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } });
|
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
|
const settle = () => new Promise(resolve => setImmediate(resolve));
|
|
function fixture(overrides = {}, shared = {}) {
|
|
let record = shared.record || null, subscription = null, permission = "default", support = "supported", config = configuration();
|
|
const calls = [], states = [];
|
|
const api = {
|
|
config: async () => config,
|
|
register: async (id, value) => { calls.push(["register", id, value]); },
|
|
revoke: async id => { calls.push(["revoke", id]); },
|
|
...overrides.api,
|
|
};
|
|
const makeSubscription = (endpoint = "https://push.example.test/opaque") => ({
|
|
toJSON: () => ({ endpoint, keys: { p256dh: "fixture-public", auth: "fixture-auth" } }),
|
|
unsubscribe: async () => { calls.push(["unsubscribe"]); subscription = null; return true; },
|
|
});
|
|
const platform = {
|
|
support: () => support, permission: () => permission,
|
|
requestPermission: () => { calls.push(["permission"]); permission = "granted"; return Promise.resolve(permission); },
|
|
subscription: async () => subscription,
|
|
subscribe: async key => { calls.push(["subscribe", key]); return subscription = makeSubscription(); },
|
|
clearNotifications: async () => { calls.push(["clear"]); },
|
|
...overrides.platform,
|
|
};
|
|
const options = {
|
|
api, platform, read: () => record, write: value => { record = structuredClone(value); }, uuid: randomUUID,
|
|
scope: async () => "a".repeat(64),
|
|
lock: async operation => operation(), changed: state => states.push(state), ...shared.options,
|
|
};
|
|
const controller = createAgentPushController(options);
|
|
return { controller, options, calls, states, api, platform, makeSubscription, record: () => record,
|
|
setConfig(value) { config = value; }, setPermission(value) { permission = value; }, setSupport(value) { support = value; }, setSubscription(value) { subscription = value; } };
|
|
}
|
|
|
|
test("explicit gesture requests permission before yielding; permission alone never opts in", async () => {
|
|
const f = fixture(); f.setPermission("granted");
|
|
await f.controller.refresh();
|
|
assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
|
const enabling = f.controller.enable();
|
|
assert.deepEqual(f.calls, [["permission"]], "request runs directly inside the click stack");
|
|
await enabling;
|
|
assert.equal(f.record().enabled, true);
|
|
assert.equal(f.record().sourceId, sourceId);
|
|
assert.equal(f.record().revocationPending, false);
|
|
assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
|
await f.controller.refresh(); await f.controller.refresh();
|
|
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "heartbeat does not repeatedly register an unchanged subscription");
|
|
});
|
|
|
|
test("denial, disabled config, unsupported browser and iOS installation guidance do not subscribe", async () => {
|
|
for (const support of ["unsupported", "install"]) {
|
|
const f = fixture(); f.setSupport(support); await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.controller.state().canEnable, false); assert.deepEqual(f.calls, []);
|
|
assert.match(f.controller.state().message, support === "install" ? /Home Screen/ : /HTTPS/);
|
|
}
|
|
const disabled = fixture(); disabled.setConfig({ ...configuration(), enabled: false });
|
|
await disabled.controller.refresh(); await disabled.controller.enable(); assert.deepEqual(disabled.calls, []);
|
|
const denied = fixture({ platform: { requestPermission: async () => "denied" } });
|
|
await denied.controller.refresh(); await denied.controller.enable();
|
|
assert.equal(denied.record().enabled, false); assert.equal(denied.calls.length, 0);
|
|
assert.match(denied.controller.state().error, /not allowed/);
|
|
});
|
|
|
|
test("registration failure rolls back subscription, persists revoke intent and never echoes provider errors", async () => {
|
|
const f = fixture({ api: { register: async () => { throw new Error("https://private-provider/token-secret"); }, revoke: async () => { throw new Error("offline"); } } });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
|
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
assert.doesNotMatch(JSON.stringify(f.states), /private-provider|token-secret/);
|
|
assert.equal(f.controller.state().canEnable, false);
|
|
});
|
|
|
|
test("offline logout writes tombstone synchronously, unsubscribes locally and retries before any enable", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const id = f.record().installationId;
|
|
f.api.revoke = async () => { throw new Error("offline"); };
|
|
const disabling = f.controller.disable();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
|
await disabling;
|
|
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
const count = f.calls.filter(([name]) => name === "register").length;
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
|
f.api.revoke = async revoked => { assert.equal(revoked, id); };
|
|
await f.controller.refresh();
|
|
assert.equal(f.record().revocationPending, false); assert.equal(f.record().enabled, false);
|
|
});
|
|
|
|
test("storage failures during logout still revoke, unsubscribe and close notifications", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
f.options.write = () => { throw new Error("quota"); };
|
|
let removed = false; f.options.remove = () => { removed = true; };
|
|
await f.controller.disable();
|
|
assert.ok(removed); assert.equal(f.controller.state().enabled, false);
|
|
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
|
await f.controller.refresh();
|
|
assert.equal(f.controller.state().enabled, false);
|
|
});
|
|
|
|
test("a stuck cross-tab lock cannot prevent server retirement or local logout cleanup", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
f.options.lock = async () => { throw new Error("lock acquisition expired"); };
|
|
await f.controller.disable();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
|
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
|
});
|
|
|
|
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
|
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
|
});
|
|
|
|
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const id = f.record().installationId;
|
|
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
|
await f.controller.refresh();
|
|
const registrations = f.calls.filter(([name]) => name === "register");
|
|
assert.equal(registrations.length, 2); assert.ok(registrations.every(([, installation]) => installation === id));
|
|
f.setPermission("denied"); await f.controller.refresh();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
|
});
|
|
|
|
test("logout while permission prompt is open cancels the older opt-in", async () => {
|
|
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
|
await f.controller.refresh(); const enabling = f.controller.enable();
|
|
await f.controller.disable(); prompt.resolve("granted"); await enabling;
|
|
assert.equal(f.record().enabled, false);
|
|
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
|
});
|
|
|
|
test("two-tab enable → disable → enable uses permanent server tombstones and a new installation UUID", async () => {
|
|
let sharedRecord = null, queue = Promise.resolve(), currentRegistration = false;
|
|
const calls = [], pendingRegister = deferred(), retired = new Set(); let first = true;
|
|
const common = { options: {
|
|
read: () => sharedRecord, write: value => { sharedRecord = structuredClone(value); },
|
|
lock: operation => { const running = queue.then(operation); queue = running.catch(() => undefined); return running; },
|
|
} };
|
|
const api = {
|
|
register: async id => { calls.push("register"); if (first) { first = false; await pendingRegister.promise; } assert.ok(!retired.has(id), "retired UUID must never be reused"); currentRegistration = true; },
|
|
revoke: async id => { calls.push("revoke"); retired.add(id); currentRegistration = false; },
|
|
};
|
|
const a = fixture({ api }, common), b = fixture({ api }, common);
|
|
await a.controller.refresh(); await b.controller.refresh();
|
|
const enabling = a.controller.enable(); await settle();
|
|
assert.deepEqual(calls, ["register"]);
|
|
const disabling = b.controller.disable();
|
|
assert.equal(sharedRecord.enabled, false); assert.equal(sharedRecord.revocationPending, true);
|
|
pendingRegister.resolve(); await Promise.all([enabling, disabling]);
|
|
assert.equal(currentRegistration, false);
|
|
const id = sharedRecord.installationId;
|
|
await b.controller.enable();
|
|
assert.equal(currentRegistration, true); assert.equal(sharedRecord.enabled, true); assert.notEqual(sharedRecord.installationId, id);
|
|
assert.ok(retired.has(id));
|
|
assert.equal(calls.at(-1), "register");
|
|
});
|
|
|
|
test("a timed-out old PUT completing after DELETE cannot revive the retired installation or affect a new opt-in", async () => {
|
|
const retired = new Set(), active = new Set(), late = deferred(); let firstId, lateStatus, first = true;
|
|
const f = fixture({ api: {
|
|
register: async id => {
|
|
if (first) {
|
|
first = false; firstId = id;
|
|
late.promise.then(() => { lateStatus = retired.has(id) ? 409 : 200; if (lateStatus === 200) active.add(id); });
|
|
throw new Error("request timed out while server is still processing");
|
|
}
|
|
assert.ok(!retired.has(id)); active.add(id);
|
|
},
|
|
revoke: async id => { retired.add(id); active.delete(id); },
|
|
} });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false); assert.ok(retired.has(firstId));
|
|
await f.controller.enable(); const currentId = f.record().installationId;
|
|
assert.notEqual(currentId, firstId); assert.ok(active.has(currentId));
|
|
late.resolve(); await settle();
|
|
assert.equal(lateStatus, 409); assert.deepEqual([...active], [currentId]);
|
|
});
|
|
|
|
test("authority changes retain original-server cleanup without issuing a misleading DELETE to the new server", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const oldId = f.record().installationId, registrations = f.calls.filter(([name]) => name === "register").length;
|
|
f.options.scope = async () => "b".repeat(64);
|
|
await f.controller.refresh(); await f.controller.refresh();
|
|
assert.equal(f.record().enabled, false);
|
|
assert.equal(f.record().revocationPending, false, "local unsubscribe succeeded, but old server cleanup stays queued");
|
|
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
|
assert.ok(!f.calls.some(([name]) => name === "revoke"), "new server's unknown-ID DELETE/204 cannot retire the original server registration");
|
|
assert.equal(f.calls.filter(([name]) => name === "register").length, registrations);
|
|
await f.controller.enable();
|
|
const newId = f.record().installationId;
|
|
assert.notEqual(newId, oldId); assert.equal(f.record().scope, "b".repeat(64)); assert.equal(f.record().enabled, true);
|
|
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
|
const reloaded = createAgentPushController(f.options);
|
|
await reloaded.refresh();
|
|
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }], "queue survives a reload on B");
|
|
assert.ok(!f.calls.some(([name]) => name === "revoke"));
|
|
f.options.scope = async () => "a".repeat(64);
|
|
await reloaded.refresh();
|
|
assert.deepEqual(f.calls.filter(([name]) => name === "revoke"), [["revoke", oldId]], "returning with A credentials retries only A's intent");
|
|
assert.deepEqual(f.record().pendingRevocations, [{ installationId: newId, scope: "b".repeat(64) }]);
|
|
assert.equal(f.record().enabled, false);
|
|
await reloaded.enable();
|
|
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "a".repeat(64));
|
|
assert.notEqual(f.record().installationId, oldId);
|
|
});
|
|
|
|
test("revoked old credentials do not prevent fresh opt-in after same-server credential rotation", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const oldId = f.record().installationId;
|
|
f.api.revoke = async () => { throw new Error("old credentials revoked"); };
|
|
await f.controller.disable();
|
|
assert.equal(f.record().revocationPending, true);
|
|
f.options.scope = async () => "c".repeat(64);
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "c".repeat(64));
|
|
assert.notEqual(f.record().installationId, oldId);
|
|
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
|
assert.doesNotMatch(JSON.stringify(f.record()), /credentials|provider|opaque|fixture-auth/);
|
|
});
|
|
|
|
test("failed old-authority retries retain intent while other queued authorities are never contacted", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
|
const queue = [{ installationId: randomUUID(), scope: "a".repeat(64) }, { installationId: randomUUID(), scope: "b".repeat(64) }];
|
|
f.options.write({ ...f.record(), pendingRevocations: queue });
|
|
const attempted = []; f.api.revoke = async id => { attempted.push(id); throw new Error("offline"); };
|
|
await f.controller.refresh();
|
|
assert.deepEqual(attempted, [queue[0].installationId]); assert.deepEqual(f.record().pendingRevocations, queue);
|
|
await f.controller.enable();
|
|
assert.equal(f.record().enabled, true); assert.deepEqual(f.record().pendingRevocations, queue);
|
|
});
|
|
|
|
test("bounded revocation queue never silently evicts cleanup and gives actionable opt-in recovery", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
|
const queue = Array.from({ length: MAX_AGENT_PUSH_REVOCATIONS }, () => ({ installationId: randomUUID(), scope: "b".repeat(64) }));
|
|
f.options.write({ ...f.record(), pendingRevocations: queue });
|
|
await f.controller.refresh();
|
|
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /Sign in to a previous server/);
|
|
const count = f.calls.filter(([name]) => name === "register").length;
|
|
await f.controller.enable();
|
|
assert.deepEqual(f.record().pendingRevocations, queue); assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
|
assert.ok(parseAgentPushConsent(JSON.stringify(f.record())));
|
|
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [...queue, queue[0]] })), null);
|
|
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [{ installationId: queue[0].installationId, scope: "raw-token" }] })), null);
|
|
f.options.scope = async () => "b".repeat(64);
|
|
await f.controller.refresh();
|
|
assert.deepEqual(f.record().pendingRevocations, []); assert.equal(f.controller.state().canEnable, true);
|
|
});
|
|
|
|
test("in-flight cleanup cannot overwrite a newer tab's consent revision", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const entered = deferred(), release = deferred();
|
|
let reads = 0;
|
|
f.options.scope = async () => {
|
|
if (++reads === 2) { entered.resolve(); await release.promise; }
|
|
return "a".repeat(64);
|
|
};
|
|
const disabling = f.controller.disable(); await entered.promise;
|
|
const newer = { ...f.record(), installationId: randomUUID(), revision: randomUUID(), enabled: true, revocationPending: false };
|
|
f.options.write(newer); release.resolve(); await disabling;
|
|
assert.deepEqual(f.record(), newer);
|
|
});
|
|
|
|
test("principal scope changing while permission is pending prevents registration", async () => {
|
|
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
|
await f.controller.refresh(); const enabling = f.controller.enable();
|
|
f.options.scope = async () => "b".repeat(64);
|
|
prompt.resolve("granted"); await enabling;
|
|
assert.equal(f.record().enabled, false); assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
|
});
|
|
|
|
test("authority changing during config fetch cannot enable against a mixed identity snapshot", async () => {
|
|
const f = fixture({ api: { config: async () => { f.options.scope = async () => "b".repeat(64); return configuration(); } } });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
|
});
|
|
|
|
test("same backend/token with a replaced source invalidates persisted consent on reload and requires a new opt-in", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const old = structuredClone(f.record());
|
|
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
|
const reloaded = createAgentPushController(f.options);
|
|
await reloaded.refresh(); await reloaded.refresh();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
|
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "source replacement cannot reuse old consent");
|
|
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === old.installationId));
|
|
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
await reloaded.enable();
|
|
assert.equal(f.record().enabled, true); assert.equal(f.record().sourceId, replacementSourceId);
|
|
assert.equal(f.record().scope, old.scope); assert.notEqual(f.record().installationId, old.installationId);
|
|
assert.equal(f.calls.filter(([name]) => name === "permission").length, 2, "replacement requires another explicit permission gesture");
|
|
});
|
|
|
|
test("null or invalid source prevents new opt-in even when transport is configured", async () => {
|
|
for (const unknown of [null, undefined, "not-a-source-uuid"]) {
|
|
const f = fixture(); f.setConfig({ ...configuration(), sourceId: unknown });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null);
|
|
assert.deepEqual(f.calls, [], "unknown source must not request permission, subscribe or register");
|
|
}
|
|
});
|
|
|
|
test("losing a known source retires old consent and never resumes it automatically", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const oldId = f.record().installationId;
|
|
f.setConfig({ ...configuration(), sourceId: null });
|
|
await f.controller.refresh();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.controller.state().canEnable, false);
|
|
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
|
f.setConfig(configuration()); await f.controller.refresh();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
|
});
|
|
|
|
test("legacy or malformed source bindings retain installation identity for retirement, not re-enrollment", async () => {
|
|
for (const invalid of [undefined, null, "invalid-source"]) {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const oldId = f.record().installationId;
|
|
const stored = parseAgentPushConsent(JSON.stringify({ ...f.record(), sourceId: invalid }));
|
|
assert.equal(stored.installationId, oldId); assert.equal(stored.sourceId, undefined);
|
|
f.options.write(stored);
|
|
await createAgentPushController(f.options).refresh();
|
|
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
|
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
|
}
|
|
});
|
|
|
|
test("source change while permission is pending prevents subscription and retires the incomplete intent", async () => {
|
|
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
|
await f.controller.refresh(); const enabling = f.controller.enable();
|
|
const oldId = f.record().installationId;
|
|
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
|
f.setPermission("granted"); prompt.resolve("granted"); await enabling;
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
|
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
|
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
|
});
|
|
|
|
test("source change while subscribe is pending unsubscribes without registering", async () => {
|
|
const entered = deferred(), release = deferred();
|
|
const f = fixture({ platform: { subscribe: async () => {
|
|
entered.resolve(); await release.promise;
|
|
const subscription = f.makeSubscription(); f.setSubscription(subscription); return subscription;
|
|
} } });
|
|
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
|
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
|
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 0);
|
|
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
});
|
|
|
|
test("source change during initial registration rolls back instead of persisting enabled consent", async () => {
|
|
const entered = deferred(), release = deferred(), registered = [], retired = [];
|
|
const f = fixture({ api: {
|
|
register: async id => { registered.push(id); entered.resolve(); await release.promise; },
|
|
revoke: async id => { retired.push(id); },
|
|
} });
|
|
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
|
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
|
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
|
assert.deepEqual(retired, registered); assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
});
|
|
|
|
test("source change during automatic rotation registration retires the source-bound installation", async () => {
|
|
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
|
const oldId = f.record().installationId, entered = deferred(), release = deferred();
|
|
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
|
f.api.register = async id => { assert.equal(id, oldId); entered.resolve(); await release.promise; };
|
|
const refreshing = f.controller.refresh(); await entered.promise;
|
|
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await refreshing;
|
|
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
|
});
|
|
|
|
test("source verification failure after registration fails closed and rolls back", async () => {
|
|
const f = fixture({ api: { register: async () => { f.api.config = async () => { throw new Error("source unavailable"); }; } } });
|
|
await f.controller.refresh(); await f.controller.enable();
|
|
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name]) => name === "revoke"));
|
|
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
});
|
|
|
|
test("attention is per-tab, ordered, focused while idle, and recent input persists until acknowledged", async () => {
|
|
const rows = [], failure = deferred(); let focus = true;
|
|
const a = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: focus }), send: async value => { rows.push(["a", value]); } });
|
|
const b = createAgentPresenceReporter({ snapshot: () => ({ visibility: "hidden", focused: true }), send: async value => { rows.push(["b", value]); } });
|
|
await a.report(); await b.report();
|
|
assert.equal(rows[0][1].focused, true); assert.equal(rows[0][1].interacted, false);
|
|
assert.equal(rows[1][1].focused, false); assert.equal(rows[1][1].sequence, 1);
|
|
a.interact(); focus = false; await a.report(); await a.report();
|
|
assert.equal(rows[2][1].interacted, true); assert.equal(rows[2][1].focused, false);
|
|
assert.equal(rows[3][1].interacted, false); assert.equal(rows[3][1].sequence, 3);
|
|
let attempts = 0;
|
|
const c = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: false }), send: async value => {
|
|
assert.equal(value.interacted, true); if (++attempts === 1) return failure.promise;
|
|
} });
|
|
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
|
|
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
|
|
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
|
|
assert.deepEqual(JSON.parse(agentAuthFrame(null, "0", "not-an-id")), { type: "auth", token: null, after: "0", clientKind: "web" });
|
|
});
|
|
|
|
test("read frames validate source/cursor and preserve existing read semantics", () => {
|
|
const event = parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "9", updatedAt: null, sourceId }));
|
|
assert.equal(applyAgentEvent(createAgentRoomState(), event).readCursor, "9");
|
|
for (const cursor of ["-1", "1e5", "9007199254740992", "1".repeat(200)]) {
|
|
assert.equal(agentPushCursor(cursor), false);
|
|
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor, updatedAt: null, sourceId })));
|
|
}
|
|
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
|
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
|
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
|
});
|
|
|
|
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
|
const id = randomUUID(), requests = []; let mode = "config";
|
|
const api = new AgentPushClient({ baseUrl: "/api", token: () => "fixture-bearer", fetch: async (url, init) => {
|
|
requests.push({ url, init });
|
|
if (mode === "revoke") return new Response(null, { status: 204 });
|
|
if (mode === "fail") return new Response(JSON.stringify({ error: "private-token-and-endpoint" }), { status: 500 });
|
|
if (mode === "register") return Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
|
if (mode === "read") return Response.json({ cursor: "0008", sourceId });
|
|
return Response.json(configuration());
|
|
} });
|
|
await api.config(); mode = "register"; await api.register(id, { endpoint: "https://provider.test/endpoint-secret" });
|
|
mode = "read"; assert.deepEqual(await api.readState(), { sourceId, cursor: "8" });
|
|
mode = "revoke"; await api.revoke(id);
|
|
mode = "fail"; await assert.rejects(api.revoke(id), error => !/private-token/.test(error.message));
|
|
for (const request of requests) {
|
|
assert.equal(request.init.headers.Authorization, "Bearer fixture-bearer");
|
|
assert.equal(request.init.cache, "no-store"); assert.equal(request.init.redirect, "error");
|
|
assert.doesNotMatch(request.url, /fixture-bearer|endpoint-secret/);
|
|
}
|
|
await assert.rejects(api.revoke("../../bad"));
|
|
const unauthenticated = new AgentPushClient({ baseUrl: "/api", token: () => null, fetch: () => { throw new Error("must not send"); } });
|
|
await assert.rejects(unauthenticated.config(), /Sign in/);
|
|
});
|
|
|
|
test("authority-guarded API refuses mismatched credentials and pins the captured bearer across async hashing", async () => {
|
|
const id = randomUUID(), requests = [], baseUrl = "https://sybil.example.test/api/";
|
|
const scope = await agentPushAuthorityScope(baseUrl, "original-fixture-token");
|
|
assert.equal(scope, await agentPushAuthorityScope("/api", "original-fixture-token", "https://sybil.example.test/"));
|
|
assert.match(scope, /^[a-f0-9]{64}$/); assert.equal(await agentPushAuthorityScope(baseUrl, null), null);
|
|
let token = "rotated-fixture-token";
|
|
const api = new AgentPushClient({ baseUrl, token: () => token, fetch: async (url, init) => {
|
|
requests.push({ url, init });
|
|
return init.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
|
} });
|
|
await assert.rejects(api.revoke(id, scope));
|
|
await assert.rejects(api.register(id, { endpoint: "https://provider.test/fixture" }, scope));
|
|
assert.deepEqual(requests, [], "wrong authority cannot send a mutation or falsely acknowledge cleanup");
|
|
token = "original-fixture-token";
|
|
const deleting = api.revoke(id, scope); token = "rotated-fixture-token"; await deleting;
|
|
token = "original-fixture-token";
|
|
const registering = api.register(id, { endpoint: "https://provider.test/fixture" }, scope); token = "rotated-fixture-token"; await registering;
|
|
assert.equal(requests.length, 2);
|
|
assert.ok(requests.every(({ init }) => init.headers.Authorization === "Bearer original-fixture-token"));
|
|
assert.ok(requests.every(({ url }) => !url.includes("fixture-token")));
|
|
});
|