Files
clean-architecture-frontend…/tests/unit/cross-tab-invalidation.test.ts
T
DongHyeonkaandClaude Opus 5 4fe924ee0f fix: harden bounded state sidecars
N-05: the conditional-validator key was a colon join over components that may
themselves contain colons, so two distinct valid bindings could collide and one
definition's ETag could be prepared for another. The key is now a validated,
byte-bounded fixed tuple encoded with JSON.stringify.

N-09: capture localStorage exactly once and compare StorageEvent.storageArea
against that object identity, so a pulse from sessionStorage or any other area
is rejected instead of matching on key and value alone. The pulse key is
registered in the storage registry as CACHE_INVALIDATION_PULSE.

N-10: race loadPage against the caller signal and re-check before observing a
page, so a non-cooperative loader can neither hold loadAll forever nor have a
post-abort completion accumulated into a successful result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:35:48 +09:00

712 lines
21 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
CACHE_INVALIDATION_PROTOCOL_VERSION,
CACHE_INVALIDATION_WIRE_LIMITS,
decodeCacheInvalidationWireEvent,
parseCacheInvalidationWireEvent,
type CacheInvalidationWireEvent,
} from "../../src/contracts/cache-invalidation.ts";
import {
createBrowserCrossContextInvalidation,
createBrowserCrossContextInvalidationFromHost,
type BroadcastChannelFacade,
type BroadcastMessageListener,
type BrowserCrossContextInvalidationDependencies,
type CrossContextInvalidationDelivery,
type CrossContextInvalidationObservation,
type StorageEventTargetFacade,
type StoragePulseFacade,
type StoragePulseListener,
} from "../../src/adapters/cross-context-invalidation/index.ts";
const NOW = 1_000_000;
const CACHE_EPOCH = "cache-epoch-0001";
const TOPIC = "sample-topic-alpha";
const CHANNEL_NAME = "cache-invalidation-v1";
const STORAGE_PULSE_KEY = "ca-frontend:cache-invalidation:v1:pulse";
class FakeBroadcastNetwork {
readonly channels: FakeBroadcastChannel[] = [];
readonly messages: unknown[] = [];
createChannel = (name: string): FakeBroadcastChannel => {
const channel = new FakeBroadcastChannel(name, this);
this.channels.push(channel);
return channel;
};
emit(value: unknown): void {
this.messages.push(value);
for (const channel of this.channels) channel.emit(value);
}
}
class FakeBroadcastChannel implements BroadcastChannelFacade {
readonly listeners = new Set<BroadcastMessageListener>();
closed = false;
failPost = false;
closeCount = 0;
removeCount = 0;
constructor(
readonly name: string,
private readonly network: FakeBroadcastNetwork,
) {}
postMessage(value: unknown): void {
if (this.closed || this.failPost) {
throw new DOMException("Broadcast failed", "InvalidStateError");
}
// Deliberately includes the sender. Native BroadcastChannel can still
// deliver through another same-context channel, so the adapter must use
// source identity rather than relying on provider echo behavior.
this.network.emit(value);
}
addEventListener(
_type: "message",
listener: BroadcastMessageListener,
): void {
this.listeners.add(listener);
}
removeEventListener(
_type: "message",
listener: BroadcastMessageListener,
): void {
this.removeCount += 1;
this.listeners.delete(listener);
}
close(): void {
this.closeCount += 1;
this.closed = true;
this.listeners.clear();
}
emit(value: unknown): void {
if (this.closed) return;
for (const listener of [...this.listeners]) {
listener({ data: value });
}
}
}
class FakeStorageEventTarget implements StorageEventTargetFacade {
readonly listeners = new Set<StoragePulseListener>();
removeCount = 0;
addEventListener(
_type: "storage",
listener: StoragePulseListener,
): void {
this.listeners.add(listener);
}
removeEventListener(
_type: "storage",
listener: StoragePulseListener,
): void {
this.removeCount += 1;
this.listeners.delete(listener);
}
emit(
key: string | null,
newValue: string | null,
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN" =
"EXPECTED_LOCAL_STORAGE",
): void {
for (const listener of [...this.listeners]) {
listener({ key, newValue, storageArea });
}
}
}
class FakeStorageBus {
readonly values = new Map<string, string>();
readonly targets = new Set<FakeStorageEventTarget>();
createEndpoint(): Readonly<{
storage: FakeStorageEndpoint;
target: FakeStorageEventTarget;
}> {
const target = new FakeStorageEventTarget();
this.targets.add(target);
return Object.freeze({
storage: new FakeStorageEndpoint(this, target),
target,
});
}
/** Simulates the event arriving from a different Storage area. */
emitAsForeignArea = false;
set(
owner: FakeStorageEventTarget,
key: string,
value: string,
): void {
this.values.set(key, value);
for (const target of this.targets) {
if (target !== owner) {
target.emit(
key,
value,
this.emitAsForeignArea
? "OTHER_OR_UNKNOWN"
: "EXPECTED_LOCAL_STORAGE",
);
}
}
}
remove(owner: FakeStorageEventTarget, key: string): void {
this.values.delete(key);
for (const target of this.targets) {
if (target !== owner) target.emit(key, null);
}
}
}
class FakeStorageEndpoint implements StoragePulseFacade {
failSet = false;
failRemove = false;
setCount = 0;
removeCount = 0;
constructor(
private readonly bus: FakeStorageBus,
private readonly owner: FakeStorageEventTarget,
) {}
setItem(key: string, value: string): void {
this.setCount += 1;
if (this.failSet) {
throw new DOMException("Storage denied", "SecurityError");
}
this.bus.set(this.owner, key, value);
}
removeItem(key: string): void {
this.removeCount += 1;
if (this.failRemove) {
throw new DOMException("Storage denied", "SecurityError");
}
this.bus.remove(this.owner, key);
}
}
function dependencies(
id: string,
overrides: Partial<BrowserCrossContextInvalidationDependencies> = {},
): BrowserCrossContextInvalidationDependencies {
let eventNumber = 0;
return {
channelName: CHANNEL_NAME,
storagePulseKey: STORAGE_PULSE_KEY,
sourceId: `${id}-source`,
sourceEpoch: `${id}-epoch`,
cacheEpoch: CACHE_EPOCH,
topics: [{ topic: TOPIC, topicVersion: 1 }],
createEventId: () =>
`${id}-event-${String(++eventNumber).padStart(8, "0")}`,
nowEpochMilliseconds: () => NOW,
...overrides,
};
}
function wireEvent(
overrides: Partial<CacheInvalidationWireEvent> = {},
): CacheInvalidationWireEvent {
return {
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
eventId: "remote-event-00000001",
sourceId: "remote-source-0000001",
sourceEpoch: "remote-epoch-0000001",
sequence: 1,
cacheEpoch: CACHE_EPOCH,
topic: TOPIC,
topicVersion: 1,
emittedAt: NOW,
expiresAt: NOW + 60_000,
...overrides,
};
}
describe("cache invalidation wire contract", () => {
const policy = {
cacheEpoch: CACHE_EPOCH,
topicVersions: { [TOPIC]: 1 },
nowEpochMilliseconds: NOW,
} as const;
it("accepts only the exact payload-free, query-key-free envelope", () => {
const accepted = parseCacheInvalidationWireEvent(wireEvent(), policy);
expect(accepted).toMatchObject({ ok: true });
if (!accepted.ok) throw new Error("Expected a valid event");
expect(Object.keys(accepted.value).sort()).toEqual([
"cacheEpoch",
"emittedAt",
"eventId",
"expiresAt",
"protocolVersion",
"sequence",
"sourceEpoch",
"sourceId",
"topic",
"topicVersion",
]);
expect(accepted.value).not.toHaveProperty("payload");
expect(accepted.value).not.toHaveProperty("queryKey");
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), payload: { secret: "must-not-cross" } },
policy,
),
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), queryKey: ["resource", "sensitive-id"] },
policy,
),
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
});
it("bounds bytes, protocol, allowlist, cache epoch and event lifetime", () => {
expect(
decodeCacheInvalidationWireEvent("{not-json", policy),
).toEqual({ ok: false, reason: "MALFORMED_JSON" });
expect(
decodeCacheInvalidationWireEvent(
"x".repeat(
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes + 1,
),
policy,
),
).toEqual({ ok: false, reason: "OVERSIZED" });
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), protocolVersion: 2 },
policy,
),
).toEqual({ ok: false, reason: "PROTOCOL_MISMATCH" });
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), topic: "unknown-topic" },
policy,
),
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), topicVersion: 2 },
policy,
),
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
expect(
parseCacheInvalidationWireEvent(
{ ...wireEvent(), cacheEpoch: "other-cache-epoch" },
policy,
),
).toEqual({
ok: false,
reason: "CACHE_EPOCH_MISMATCH",
});
expect(
parseCacheInvalidationWireEvent(
wireEvent({ emittedAt: NOW - 60_000, expiresAt: NOW }),
policy,
),
).toEqual({ ok: false, reason: "EXPIRED" });
});
});
describe("browser cross-context host", () => {
it("does not inspect browser capabilities when no topic is installed", () => {
const reads: string[] = [];
const host = new Proxy<Record<string, unknown>>(
{},
{
get(_target, property) {
reads.push(String(property));
throw new DOMException("Capability access denied", "SecurityError");
},
},
);
expect(
createBrowserCrossContextInvalidationFromHost({
host,
cacheEpoch: CACHE_EPOCH,
topics: [],
}),
).toBeUndefined();
expect(reads).toEqual([]);
});
});
describe("browser cross-context invalidation transport", () => {
it("publishes one exact BroadcastChannel event and filters self echo", () => {
const network = new FakeBroadcastNetwork();
const first = createBrowserCrossContextInvalidation(
dependencies("tab-a", {
createBroadcastChannel: network.createChannel,
}),
);
const second = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
}),
);
const local = vi.fn();
const remote = vi.fn();
first.subscribe(local);
second.subscribe(remote);
expect(first.getStatus()).toBe("ACTIVE_BROADCAST");
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
ok: true,
transport: "BROADCAST",
});
expect(local).not.toHaveBeenCalled();
expect(remote).toHaveBeenCalledOnce();
expect(remote.mock.calls[0]?.[0]).toMatchObject({
ordering: "NEXT",
transport: "BROADCAST",
event: {
protocolVersion: 1,
sequence: 1,
cacheEpoch: CACHE_EPOCH,
topic: TOPIC,
topicVersion: 1,
},
});
expect(network.messages).toHaveLength(1);
expect(JSON.stringify(network.messages[0])).not.toMatch(
/payload|queryKey|sensitive/,
);
first.close();
second.close();
});
it("de-duplicates the same event delivered by both transports", () => {
const network = new FakeBroadcastNetwork();
const storage = new FakeStorageBus().createEndpoint();
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
storage: storage.storage,
storageEvents: storage.target,
}),
);
const received = vi.fn();
runtime.subscribe(received);
const event = wireEvent();
network.emit(event);
storage.target.emit(STORAGE_PULSE_KEY, JSON.stringify(event));
expect(received).toHaveBeenCalledOnce();
expect(received.mock.calls[0]?.[0]).toMatchObject({
transport: "BROADCAST",
ordering: "NEXT",
});
runtime.close();
});
it("reports per-source gaps and drops stale out-of-order events", () => {
const network = new FakeBroadcastNetwork();
const observations: CrossContextInvalidationObservation[] = [];
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
observe: (observation) => observations.push(observation),
}),
);
const delivered: CrossContextInvalidationDelivery[] = [];
runtime.subscribe((delivery) => delivered.push(delivery));
network.emit(wireEvent({ eventId: "remote-event-00000001", sequence: 1 }));
network.emit(wireEvent({ eventId: "remote-event-00000003", sequence: 3 }));
network.emit(wireEvent({ eventId: "remote-event-00000002", sequence: 2 }));
network.emit(
wireEvent({
eventId: "remote-new-epoch-event",
sourceEpoch: "remote-epoch-0000002",
sequence: 1,
}),
);
expect(
delivered.map(({ event, ordering }) => [
event.sequence,
ordering,
]),
).toEqual([
[1, "NEXT"],
[3, "GAP"],
[1, "NEXT"],
]);
expect(observations).toContainEqual({
operation: "RECEIVE",
outcome: "DROPPED",
transport: "BROADCAST",
reason: "STALE",
});
runtime.close();
});
it("falls back when BroadcastChannel open or publish fails", () => {
const storageBus = new FakeStorageBus();
const firstStorage = storageBus.createEndpoint();
const secondStorage = storageBus.createEndpoint();
const firstNetwork = new FakeBroadcastNetwork();
const secondNetwork = new FakeBroadcastNetwork();
const first = createBrowserCrossContextInvalidation(
dependencies("tab-a", {
createBroadcastChannel: firstNetwork.createChannel,
storage: firstStorage.storage,
storageEvents: firstStorage.target,
}),
);
const second = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: () => {
throw new DOMException("Denied", "SecurityError");
},
storage: secondStorage.storage,
storageEvents: secondStorage.target,
}),
);
firstNetwork.channels[0]!.failPost = true;
const received = vi.fn();
second.subscribe(received);
expect(second.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
ok: true,
transport: "STORAGE",
});
expect(first.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
expect(received).toHaveBeenCalledOnce();
expect(firstStorage.storage.setCount).toBe(1);
expect(firstStorage.storage.removeCount).toBe(1);
expect(storageBus.values.has(STORAGE_PULSE_KEY)).toBe(false);
expect(secondNetwork.channels).toHaveLength(0);
first.close();
second.close();
});
it("rejects storage pulses from another or unknown storage area", () => {
const storageBus = new FakeStorageBus();
const publisherStorage = storageBus.createEndpoint();
const receiverStorage = storageBus.createEndpoint();
const storageOnly = {
createBroadcastChannel: () => {
throw new DOMException("Denied", "SecurityError");
},
};
const publisher = createBrowserCrossContextInvalidation(
dependencies("tab-a", {
...storageOnly,
storage: publisherStorage.storage,
storageEvents: publisherStorage.target,
}),
);
const receiver = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
...storageOnly,
storage: receiverStorage.storage,
storageEvents: receiverStorage.target,
}),
);
const received = vi.fn();
receiver.subscribe(received);
// N-09. sessionStorage and every other Storage area raise the same event,
// so an identical key and value from a foreign area must be ignored.
storageBus.emitAsForeignArea = true;
expect(
publisher.publish({ topic: TOPIC, topicVersion: 1 }),
).toMatchObject({ ok: true });
expect(received).not.toHaveBeenCalled();
storageBus.emitAsForeignArea = false;
expect(
publisher.publish({ topic: TOPIC, topicVersion: 1 }),
).toMatchObject({ ok: true });
expect(received).toHaveBeenCalledOnce();
publisher.close();
receiver.close();
});
it("enters explicit local-only degradation when every transport fails", () => {
const storageBus = new FakeStorageBus();
const endpoint = storageBus.createEndpoint();
endpoint.storage.failSet = true;
const observations: CrossContextInvalidationObservation[] = [];
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-a", {
createBroadcastChannel: () => {
throw new DOMException("Denied", "SecurityError");
},
storage: endpoint.storage,
storageEvents: endpoint.target,
observe: (observation) => observations.push(observation),
}),
);
expect(runtime.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
ok: false,
reason: "TRANSPORT_UNAVAILABLE",
});
expect(runtime.getStatus()).toBe("DEGRADED_LOCAL_ONLY");
expect(observations).toContainEqual({
operation: "PUBLISH",
outcome: "DEGRADED",
transport: "STORAGE",
reason: "STORAGE_PUBLISH_FAILED",
});
runtime.close();
});
it("isolates handler and diagnostics failures without leaking identifiers", () => {
const network = new FakeBroadcastNetwork();
const observations: CrossContextInvalidationObservation[] = [];
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
observe(observation) {
observations.push(observation);
if (observation.reason === "HANDLER_FAILED") {
throw new Error("diagnostics unavailable");
}
},
}),
);
const healthy = vi.fn();
runtime.subscribe(() => {
throw new Error("listener secret");
});
runtime.subscribe(healthy);
network.emit(
wireEvent({
eventId: "sensitive-event-identifier",
topic: TOPIC,
}),
);
expect(healthy).toHaveBeenCalledOnce();
expect(observations.some(({ reason }) => reason === "HANDLER_FAILED")).toBe(
true,
);
expect(JSON.stringify(observations)).not.toMatch(
/sensitive-event-identifier|sample-topic-alpha|cache-epoch-0001/,
);
runtime.close();
});
it("bounds TTL and LRU tracking instead of growing with remote sources", () => {
const network = new FakeBroadcastNetwork();
let currentTime = NOW;
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
nowEpochMilliseconds: () => currentTime,
dedupeCapacity: 2,
sourceCapacity: 2,
}),
);
const received = vi.fn();
runtime.subscribe(received);
const distinctSource = (
index: number,
eventId: string,
emittedAt = currentTime,
) =>
wireEvent({
eventId,
sourceId: `remote-source-${index}`,
sourceEpoch: `remote-epoch-${index}`,
emittedAt,
expiresAt: emittedAt + 1_000,
});
network.emit(distinctSource(1, "bounded-event-1"));
network.emit(distinctSource(2, "bounded-event-2"));
network.emit(distinctSource(3, "bounded-event-3"));
network.emit(distinctSource(1, "bounded-event-1"));
expect(received).toHaveBeenCalledTimes(4);
currentTime += 1_001;
network.emit(
distinctSource(3, "bounded-event-3", currentTime),
);
expect(received).toHaveBeenCalledTimes(5);
runtime.close();
});
it("cleans up listeners once and rejects queued work after close", () => {
const network = new FakeBroadcastNetwork();
const storage = new FakeStorageBus().createEndpoint();
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-b", {
createBroadcastChannel: network.createChannel,
storage: storage.storage,
storageEvents: storage.target,
}),
);
const received = vi.fn();
const unsubscribe = runtime.subscribe(received);
unsubscribe();
unsubscribe();
runtime.subscribe(received);
runtime.close();
runtime.close();
network.emit(wireEvent());
storage.target.emit(
STORAGE_PULSE_KEY,
JSON.stringify(wireEvent()),
);
expect(runtime.getStatus()).toBe("CLOSED");
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
ok: false,
reason: "CLOSED",
});
expect(received).not.toHaveBeenCalled();
expect(network.channels[0]?.closeCount).toBe(1);
expect(network.channels[0]?.removeCount).toBe(1);
expect(storage.target.removeCount).toBe(1);
expect(storage.target.listeners.size).toBe(0);
});
it("fails invalid publish inputs closed without touching a provider", () => {
const network = new FakeBroadcastNetwork();
const runtime = createBrowserCrossContextInvalidation(
dependencies("tab-a", {
createBroadcastChannel: network.createChannel,
}),
);
expect(
runtime.publish({ topic: "not-allowlisted", topicVersion: 1 }),
).toEqual({ ok: false, reason: "INVALID_EVENT" });
expect(
runtime.publish({ topic: TOPIC, topicVersion: 2 }),
).toEqual({ ok: false, reason: "INVALID_EVENT" });
expect(network.messages).toHaveLength(0);
runtime.close();
});
});