mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
chord/next/http: a Web fetch handler built from the registry Http (routes, wraps, sockets, hosts, relays, CSP sources) and Authenticators, responses as descriptions, a security baseline on by default, Chord's own Node listener as a host-lifetime object, nodeRequest for apps mounting it, links and plugin sockets. chord/next/workers: a forked worker per address, started on demand, stopped when idle, never restarted by itself, plugins built by the server and shipped. Plugins: kinds with platform and host packages, createPluginRuntime, scripts(kind), rollout results per host, imports of other plugins by name. Kernel: RouteHandle (retry, suspend), no route backoff, anonymous host.root, origins only from relays. ws is an optional peer dependency.
586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
// Tests of revocation (§4, R10, R11): revocation keys, \`host.revoke\` on one host (objects, running calls, activations,
|
|
// holds bound or not), child links and \`revoke\` messages, GC, and revocation across a relay.
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
|
import { hostKind, int, local, remote } from "../../../src/next/contracts/index.ts";
|
|
import { createHost, defineFacet, type FacetHost, type Host } from "../../../src/next/host/index.ts";
|
|
import { ChordObject } from "../../../src/next/host/object.ts";
|
|
import { type Link, memoryLinkPair } from "../../../src/next/link/index.ts";
|
|
import { jsonPart } from "../../../src/next/link/internal.ts";
|
|
import { constant } from "../../../src/next/state/index.ts";
|
|
import {
|
|
expectCollected,
|
|
forEachCodec,
|
|
instancesOf,
|
|
Notes,
|
|
PlacedNotes,
|
|
RawPeer,
|
|
rawParent,
|
|
rawWorkers,
|
|
rejection,
|
|
relayed,
|
|
SERVER_ADDRESS,
|
|
Server,
|
|
Session,
|
|
serveNotes,
|
|
type TestPrincipal,
|
|
testCodec,
|
|
transported,
|
|
wireNames,
|
|
} from "../support.ts";
|
|
|
|
/** Every object attempted in this file, weakly. */
|
|
const objects = instancesOf(ChordObject, "attempt");
|
|
|
|
/** The test app's revocations: a guest's session, or every owner. */
|
|
type Revocation = { session: string } | { owners: true };
|
|
|
|
const revokedBy = (principal: TestPrincipal): Revocation[] =>
|
|
principal.kind === "guest"
|
|
? [{ session: principal.session }]
|
|
: principal.kind === "owner"
|
|
? [{ owners: true }]
|
|
: principal.kind === "worker"
|
|
? [{ session: principal.session }]
|
|
: [];
|
|
|
|
const S1: TestPrincipal = { kind: "guest", session: "S1" };
|
|
|
|
/** A host-lifetime type whose activation waits for an open: `load` waits for it (the start barrier). */
|
|
const Barrier = local("app.barrier", { version: 1, params: {}, members: { n: int() } });
|
|
|
|
/** A guest's variant: its `wait` method's index. */
|
|
const { wait } = wireNames(Notes, "reader").methods as { wait: number };
|
|
|
|
/** A worker of session S1 serving `Notes` (as `serveNotes`, at the address `Notes` names), its parent a raw peer whose holds name their principal. */
|
|
async function worker(setup: (host: FacetHost<TestPrincipal>) => void = (h) => void serveNotes(h)) {
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
address: Server(),
|
|
principal: { kind: "worker", session: "S1" },
|
|
revokedBy,
|
|
onError: () => {},
|
|
});
|
|
await host.load([{ name: "@test/worker", facet: defineFacet<TestPrincipal>(setup) }], context);
|
|
const parent = await rawParent(host);
|
|
return { host, parent };
|
|
}
|
|
|
|
/** Sends a hold of `Notes` for S1 with id `hold`, acting as `principal` (honoured: the peer is the parent). */
|
|
function holdAs(peer: RawPeer, hold: number, principal: TestPrincipal, Type: typeof Notes = Notes): void {
|
|
peer.send({
|
|
kind: "hold",
|
|
hold,
|
|
type: Type.name,
|
|
version: Type.version,
|
|
fingerprint: Type.fingerprint,
|
|
params: jsonPart({ session: "S1" }),
|
|
address: SERVER_ADDRESS,
|
|
principal,
|
|
});
|
|
}
|
|
|
|
describe("revocation keys", () => {
|
|
it("are computed once per principal object, compared as canonical JSON", async () => {
|
|
let calls = 0;
|
|
const host = createHost<TestPrincipal, { a: number; b: number }>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
revokedBy: () => {
|
|
calls++;
|
|
return [{ b: 2, a: 1 }];
|
|
},
|
|
onError: () => {},
|
|
});
|
|
await host.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => serveNotes(h)) }], context);
|
|
const peer = await rawParent(host);
|
|
holdAs(peer, 0, S1);
|
|
holdAs(peer, 1, { kind: "guest", session: "S2" });
|
|
await peer.next("bound");
|
|
await peer.next("bound");
|
|
host.revoke({ a: 1, b: 2 }, context);
|
|
host.revoke({ a: 1, b: 3 }, context);
|
|
await transported();
|
|
expect(calls).toBe(2);
|
|
expect((await peer.next("gone")).hold).toBe(0);
|
|
expect((await peer.next("gone")).hold).toBe(1);
|
|
});
|
|
|
|
it("keep no principal alive (a WeakMap keyed by the principal the host cached them for)", async () => {
|
|
const cached: WeakRef<TestPrincipal>[] = [];
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
address: Server(),
|
|
principal: { kind: "worker", session: "S1" },
|
|
revokedBy: (principal) => {
|
|
// The principal decoded on this host, the cache's key: not the sender's object.
|
|
cached.push(new WeakRef(principal));
|
|
return revokedBy(principal);
|
|
},
|
|
onError: () => {},
|
|
});
|
|
await host.load([{ name: "@test/worker", facet: defineFacet<TestPrincipal>((h) => serveNotes(h)) }], context);
|
|
const parent = await rawParent(host);
|
|
holdAs(parent, 0, { kind: "guest", session: "S9" });
|
|
await parent.next("bound");
|
|
host.revoke({ session: "S1" }, context);
|
|
parent.send({ kind: "release", hold: 0 });
|
|
await transported();
|
|
expect(cached.length).toBeGreaterThan(0);
|
|
await expectCollected(...cached);
|
|
// The cache's owner is still alive and working.
|
|
holdAs(parent, 1, S1);
|
|
await parent.next("bound");
|
|
host.revoke({ session: "S1" }, context);
|
|
expect((await parent.next("gone")).hold).toBe(1);
|
|
await host.close(context);
|
|
});
|
|
});
|
|
|
|
forEachCodec(() => {
|
|
describe("revoke on one host", () => {
|
|
it("ends the principal's objects at once, whatever references remain: each hold gets gone forbidden", async () => {
|
|
const { host, parent } = await worker();
|
|
holdAs(parent, 0, S1);
|
|
holdAs(parent, 1, S1);
|
|
holdAs(parent, 2, { kind: "owner" });
|
|
for (let i = 0; i < 3; i++) await parent.next("bound");
|
|
host.revoke({ session: "S1" }, context);
|
|
await transported();
|
|
const gone = [await parent.next("gone"), await parent.next("gone")];
|
|
expect(gone.map(({ hold }) => hold).sort()).toEqual([0, 1]);
|
|
expect(gone[0]!.error).toEqual({
|
|
kind: "chord",
|
|
error: { code: "forbidden", reason: "revoked", message: "app.notes(S1): revoked" },
|
|
});
|
|
// The owner's hold, the parent link and the local store stay.
|
|
expect(parent.closed).toBe(false);
|
|
expect(parent.kinds()).toEqual([]);
|
|
holdAs(parent, 3, S1);
|
|
expect((await parent.next("bound")).holds[0]!.hold).toBe(3);
|
|
});
|
|
|
|
it("aborts running calls (race 2): the handler's context, no result; a call crossing the gone is ignored", async () => {
|
|
let probe: ReturnType<typeof serveNotes> | undefined;
|
|
const { host, parent } = await worker((h) => {
|
|
probe = serveNotes(h);
|
|
});
|
|
holdAs(parent, 0, S1);
|
|
await parent.next("bound");
|
|
parent.send({ kind: "call", call: 0, hold: 0, method: wait });
|
|
await transported();
|
|
host.revoke({ session: "S1" }, context);
|
|
expect(probe!.waits[0]!.abortSignal?.reason).toMatchObject({ code: "forbidden" });
|
|
expect((await parent.next("gone")).hold).toBe(0);
|
|
await transported();
|
|
expect(parent.kinds()).toEqual([]);
|
|
parent.send({ kind: "call", call: 1, hold: 0, method: wait });
|
|
await transported();
|
|
expect(parent.kinds()).toEqual([]);
|
|
});
|
|
|
|
it("abandons an activation in progress (race 1): its open's context is aborted, its holders get gone", async () => {
|
|
const Slow = remote("app.slow", {
|
|
version: 1,
|
|
params: {},
|
|
hostAddress: () => Server(),
|
|
variants: { v: { state: { n: int() } } },
|
|
});
|
|
const aborted: unknown[] = [];
|
|
const { host, parent } = await worker((h) =>
|
|
h.serve(Slow, (_, _principal, self) => {
|
|
self.open(
|
|
(opening) =>
|
|
new Promise((_, reject) =>
|
|
opening.abortSignal?.addEventListener("abort", () => {
|
|
aborted.push(opening.abortSignal?.reason);
|
|
reject(opening.abortSignal?.reason);
|
|
}),
|
|
),
|
|
() => {},
|
|
);
|
|
return { variant: "v", members: { n: constant(1) } };
|
|
}),
|
|
);
|
|
parent.send({
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: Slow.name,
|
|
version: Slow.version,
|
|
fingerprint: Slow.fingerprint,
|
|
params: "{}",
|
|
address: SERVER_ADDRESS,
|
|
principal: S1,
|
|
});
|
|
await transported();
|
|
host.revoke({ session: "S1" }, context);
|
|
expect((await parent.next("gone")).error).toMatchObject({ kind: "chord", error: { code: "forbidden" } });
|
|
expect(aborted).toMatchObject([{ code: "forbidden" }]);
|
|
});
|
|
|
|
it("never revokes the host's own principal", async () => {
|
|
const { host } = await worker();
|
|
const own = await host.get(Notes, { session: "S1" }, context);
|
|
host.revoke({ session: "S1" }, context);
|
|
expect(own.state.value.kind).toBe("live");
|
|
await host.close(context);
|
|
});
|
|
|
|
it("ends a hold the parent sent before its revoke while it still waits for load (race 3): gone, never bound", async () => {
|
|
for (const delayed of [false, true]) {
|
|
// A worker connects before it loads (W6); a load can wait for an activation (the start barrier).
|
|
const opened = Promise.withResolvers<void>();
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
address: Server(),
|
|
principal: { kind: "worker", session: "S1" },
|
|
revokedBy,
|
|
onError: () => {},
|
|
});
|
|
const parent = await rawParent(host);
|
|
const facet = defineFacet<TestPrincipal>((h) => {
|
|
serveNotes(h);
|
|
if (delayed) {
|
|
h.serve(Barrier, (_params, self) => {
|
|
self.open(
|
|
() => opened.promise,
|
|
() => {},
|
|
);
|
|
return { n: constant(0) };
|
|
});
|
|
}
|
|
});
|
|
const loading = host.load([{ name: "@test/worker", facet }], context);
|
|
holdAs(parent, 0, S1);
|
|
parent.send({ kind: "revoke", revocation: { session: "S1" } });
|
|
await transported();
|
|
opened.resolve();
|
|
await loading;
|
|
await transported();
|
|
expect(await parent.next("gone")).toMatchObject({
|
|
hold: 0,
|
|
error: { kind: "chord", error: { code: "forbidden", reason: "revoked" } },
|
|
});
|
|
expect(parent.kinds()).toEqual([]);
|
|
expect(host.references(Notes).value).toEqual([]);
|
|
await host.close(context);
|
|
}
|
|
});
|
|
|
|
it("ends a relayed hold of the principal still waiting for its route's link: never held there", async () => {
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
principal: { kind: "worker", session: "S0" },
|
|
revokedBy,
|
|
onError: () => {},
|
|
});
|
|
const resolving = Promise.withResolvers<void>();
|
|
const routed: RawPeer[] = [];
|
|
host.route(
|
|
Session,
|
|
{
|
|
authorize: () => true,
|
|
resolve: async () => {
|
|
await resolving.promise;
|
|
const [a, b] = memoryLinkPair();
|
|
routed.push(new RawPeer(b));
|
|
return { link: a, principal: { kind: "worker", session: "S1" }, codec: testCodec() };
|
|
},
|
|
},
|
|
context,
|
|
);
|
|
await host.load([], context);
|
|
const parent = await rawParent(host);
|
|
const { name: type, version, fingerprint } = PlacedNotes;
|
|
const address = { kind: "session", params: { id: "S1" } };
|
|
parent.send({
|
|
kind: "hold",
|
|
hold: 0,
|
|
type,
|
|
version,
|
|
fingerprint,
|
|
params: jsonPart({ session: "S1" }),
|
|
address,
|
|
principal: S1,
|
|
});
|
|
await transported();
|
|
parent.send({ kind: "revoke", revocation: { session: "S1" } });
|
|
expect(await parent.next("gone")).toMatchObject({
|
|
hold: 0,
|
|
error: { kind: "chord", error: { code: "forbidden", reason: "revoked" } },
|
|
});
|
|
resolving.resolve();
|
|
await transported();
|
|
await transported();
|
|
expect(routed.flatMap((peer) => peer.received.filter(({ kind }) => kind === "hold"))).toEqual([]);
|
|
await host.close(context);
|
|
});
|
|
|
|
it("ends an unbound hold of the principal (refused, waiting for a provider): gone, nothing left of it", async () => {
|
|
const { host, parent } = await worker();
|
|
const Absent = { ...Notes, name: "app.absent" } as unknown as typeof Notes;
|
|
holdAs(parent, 0, S1, Absent);
|
|
expect(await parent.next("failed")).toMatchObject({ hold: 0, cause: { kind: "waiting" } });
|
|
expect(host.activity.value.references).toBe(1);
|
|
parent.send({ kind: "revoke", revocation: { session: "S1" } });
|
|
expect(await parent.next("gone")).toMatchObject({
|
|
hold: 0,
|
|
error: { kind: "chord", error: { code: "forbidden", reason: "revoked" } },
|
|
});
|
|
await transported();
|
|
expect(host.activity.value.references).toBe(0);
|
|
await host.close(context);
|
|
});
|
|
|
|
it("binds a hold made for a revoked principal, then the parent's revoke after it ends it (race 3, R10's window)", async () => {
|
|
const { host, parent } = await worker();
|
|
holdAs(parent, 0, S1);
|
|
await parent.next("bound");
|
|
// The parent's revoke follows the hold on the same link, so it reaches the worker after it.
|
|
parent.send({ kind: "revoke", revocation: { session: "S1" } });
|
|
expect((await parent.next("gone")).error).toMatchObject({ kind: "chord", error: { code: "forbidden" } });
|
|
await host.close(context);
|
|
});
|
|
});
|
|
|
|
describe("links and revoke messages", () => {
|
|
it("closes the child links whose principal it revokes, never the parent link", async () => {
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
revokedBy,
|
|
onError: () => {},
|
|
});
|
|
await host.load([], context);
|
|
const peers = [S1, { kind: "guest", session: "S2" } as const, S1].map((principal) => {
|
|
const [a, b] = memoryLinkPair();
|
|
host.accept(a, { principal, codec: testCodec() }, context);
|
|
return new RawPeer(b);
|
|
});
|
|
const parent = await rawParent(host);
|
|
host.revoke({ session: "S1" }, context);
|
|
await transported();
|
|
// Two tabs of one grant: both links close (race 4).
|
|
expect(peers.map(({ closed }) => closed)).toEqual([true, false, true]);
|
|
expect(parent.closed).toBe(false);
|
|
// A revocation is no message for a child, nor for the parent.
|
|
expect(peers[1]!.kinds()).toEqual(["hello"]);
|
|
expect(parent.kinds()).toEqual([]);
|
|
});
|
|
|
|
it("ignores a revoke from a child link (race 6)", async () => {
|
|
const host = createHost<TestPrincipal, Revocation>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
revokedBy,
|
|
onError: () => {},
|
|
});
|
|
await host.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => serveNotes(h)) }], context);
|
|
const [a, b] = memoryLinkPair();
|
|
host.accept(a, { principal: S1, codec: testCodec() }, context);
|
|
const child = new RawPeer(b);
|
|
await child.next("hello");
|
|
holdAs(child, 0, S1);
|
|
await child.next("bound");
|
|
child.send({ kind: "revoke", revocation: { session: "S1" } });
|
|
await transported();
|
|
expect(child.closed).toBe(false);
|
|
expect(child.kinds()).toEqual([]);
|
|
});
|
|
|
|
it("sends revoke on every route link, and on none made later (race 5: nothing is replayed)", async () => {
|
|
const workers: RawPeer[] = [];
|
|
const relay = await relayed({ resolve: rawWorkers(workers) });
|
|
const server = relay.server as unknown as Host<TestPrincipal, Revocation>;
|
|
relay.tab.hold(PlacedNotes, { session: "S1" }, context);
|
|
await transported();
|
|
server.revoke({ session: "S7" }, context);
|
|
await transported();
|
|
expect(workers[0]!.received.filter(({ kind }) => kind === "revoke")).toEqual([
|
|
{ kind: "revoke", revocation: { session: "S7" } },
|
|
]);
|
|
relay.tab.hold(PlacedNotes, { session: "S2" }, context);
|
|
await transported();
|
|
expect(workers[1]!.received.some(({ kind }) => kind === "revoke")).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("GC of revoked objects", () => {
|
|
it("keeps no revoked object, however many references its holders still have", async () => {
|
|
const { host, refs, parent } = await (async () => {
|
|
const { host, parent } = await worker();
|
|
const made = objects.length;
|
|
holdAs(parent, 0, S1);
|
|
await parent.next("bound");
|
|
const refs = objects.slice(made);
|
|
expect(refs.length).toBeGreaterThan(0);
|
|
host.revoke({ session: "S1" }, context);
|
|
await parent.next("gone");
|
|
return { host, refs, parent };
|
|
})();
|
|
await expectCollected(...refs.filter((ref: WeakRef<ChordObject>) => ref.deref()?.served.kind === "remote"));
|
|
expect(parent.closed).toBe(false);
|
|
await host.close(context);
|
|
});
|
|
});
|
|
|
|
describe("revocation across a relay", () => {
|
|
it("closes a revoked tab's link at the server and ends its objects on the worker", async () => {
|
|
const relay = await relayed({ tab: S1, revokedBy });
|
|
const notes = relay.tab.hold(PlacedNotes, { session: "S1" }, context);
|
|
await transported();
|
|
await transported();
|
|
expect(notes.state.value.kind).toBe("live");
|
|
const worker = relay.workers.get("session|S1")!;
|
|
const present = worker.references(PlacedNotes);
|
|
expect(present.value).toHaveLength(1);
|
|
const server = relay.server as unknown as Host<TestPrincipal, Revocation>;
|
|
server.revoke({ session: "S1" }, context);
|
|
await transported();
|
|
await transported();
|
|
// The tab's link closed: its reference waits for a new link, its holds on the worker are released.
|
|
expect(notes.state.value.kind).toBe("loading");
|
|
expect(present.value).toEqual([]);
|
|
});
|
|
|
|
it("ends the worker's objects of the principal through the revoke message alone; the relay forwards their gone", async () => {
|
|
// The server's own revokedBy names no guest: its tab link stays, only the worker applies the revocation.
|
|
const relay = await relayed({ tab: S1, revokedBy, serverRevokedBy: () => [] });
|
|
const notes = relay.tab.hold(PlacedNotes, { session: "S1" }, context);
|
|
await transported();
|
|
await transported();
|
|
expect(notes.state.value.kind).toBe("live");
|
|
const server = relay.server as unknown as Host<TestPrincipal, Revocation>;
|
|
server.revoke({ session: "S1" }, context);
|
|
await transported();
|
|
await transported();
|
|
expect(notes.state.value.kind).toBe("ended");
|
|
expect(relay.workers.get("session|S1")!.references(PlacedNotes).value).toEqual([]);
|
|
expect(relay.errors.tab).toEqual([]);
|
|
// The tab's link stayed open: a new hold binds.
|
|
const again = await relay.tab.get(PlacedNotes, { session: "S1" }, context);
|
|
expect(again.state.value.kind).toBe("live");
|
|
});
|
|
});
|
|
|
|
describe("revocation of this host's own requests over its paths (fix round 1)", () => {
|
|
type Guest = { kind: "owner" } | { kind: "guest"; grant: string };
|
|
const There = hostKind("app-there");
|
|
const Remote = remote("app.remote", {
|
|
version: 1,
|
|
params: {},
|
|
hostAddress: () => There(),
|
|
variants: { view: { state: {}, methods: {} } },
|
|
});
|
|
const guestRevokedBy = (p: Guest) => (p.kind === "guest" ? [p.grant] : []);
|
|
|
|
it("ends a guest's ctx.get waiting for its route before any link exists, at once (Astra 1, Sol 1)", async () => {
|
|
const host = createHost<Guest, string>({
|
|
principal: { kind: "owner" },
|
|
revokedBy: guestRevokedBy,
|
|
onError: () => {},
|
|
});
|
|
host.route(There, { authorize: () => true, resolve: () => new Promise(() => {}) }, context);
|
|
await host.load([], context);
|
|
let outcome: unknown;
|
|
void rejection(
|
|
host.root((ctx) => ctx.get(Remote, {}, { readyTimeout: 60_000 }), context, {
|
|
principal: { kind: "guest", grant: "g" },
|
|
}),
|
|
).then((error) => {
|
|
outcome = error;
|
|
});
|
|
await transported();
|
|
host.revoke("g", context);
|
|
await transported();
|
|
expect(outcome).toMatchObject({ code: "forbidden", reason: "revoked" });
|
|
await host.close(context);
|
|
});
|
|
|
|
it("never lets the revoked request bind on the route's later link (Astra 1)", async () => {
|
|
const host = createHost<Guest, string>({
|
|
principal: { kind: "owner" },
|
|
revokedBy: guestRevokedBy,
|
|
onError: () => {},
|
|
});
|
|
const gate = Promise.withResolvers<{ link: Link; principal: Guest; trusted: boolean }>();
|
|
host.route(There, { authorize: () => true, resolve: () => gate.promise }, context);
|
|
await host.load([], context);
|
|
const pending = rejection(
|
|
host.root(
|
|
async (ctx) => {
|
|
await ctx.get(Remote, {}, { readyTimeout: 500 });
|
|
throw new Error("revoked request became live");
|
|
},
|
|
context,
|
|
{ principal: { kind: "guest", grant: "g" } },
|
|
),
|
|
);
|
|
await transported();
|
|
host.revoke("g", context);
|
|
const worker = createHost<Guest, string>({
|
|
address: There(),
|
|
principal: { kind: "owner" },
|
|
revokedBy: guestRevokedBy,
|
|
onError: () => {},
|
|
});
|
|
const [up, down] = memoryLinkPair();
|
|
worker.connect(up, { principal: { kind: "owner" }, trusted: true }, context);
|
|
const facet = defineFacet<Guest>((h) => h.serve(Remote, () => ({ variant: "view", members: {} })));
|
|
await worker.load([{ name: "@test/late", facet }], context);
|
|
gate.resolve({ link: down, principal: { kind: "owner" }, trusted: true });
|
|
expect(await pending).toMatchObject({ code: "forbidden", reason: "revoked" });
|
|
await transported();
|
|
expect(worker.activity.value.references).toBe(0);
|
|
await host.close(context);
|
|
await worker.close(context);
|
|
});
|
|
|
|
it("ends a guest's reference over a route link that dropped, and nothing holds it on the next one", async () => {
|
|
const routed: RawPeer[] = [];
|
|
// The drop resolves again at once (slice 10 R2); the next link comes only after the revocation.
|
|
let next: () => void = () => {};
|
|
const revoked = new Promise<void>((resolve) => {
|
|
next = resolve;
|
|
});
|
|
const host = createHost<Guest, string>({
|
|
principal: { kind: "owner" },
|
|
revokedBy: guestRevokedBy,
|
|
onError: () => {},
|
|
});
|
|
host.route(
|
|
There,
|
|
{
|
|
authorize: () => true,
|
|
resolve: async () => {
|
|
if (routed.length > 0) await revoked;
|
|
const [a, b] = memoryLinkPair();
|
|
routed.push(new RawPeer(b));
|
|
return { link: a, principal: { kind: "owner" }, codec: testCodec() };
|
|
},
|
|
},
|
|
context,
|
|
);
|
|
await host.load([], context);
|
|
const states: string[] = [];
|
|
void host.root(
|
|
async (ctx) => {
|
|
const held = await ctx.get(Remote, {}, { readyTimeout: 60_000 }).catch((error: Error) => error);
|
|
states.push(held instanceof Error ? held.message : "live");
|
|
},
|
|
context,
|
|
{ principal: { kind: "guest", grant: "g" } },
|
|
);
|
|
await transported();
|
|
const first = routed[0]!;
|
|
await first.next("hold");
|
|
first.close();
|
|
await transported();
|
|
host.revoke("g", context);
|
|
next();
|
|
await transported();
|
|
expect(states).toEqual(["app.remote: revoked"]);
|
|
expect(routed.flatMap((peer, i) => (i === 0 ? [] : peer.received.filter(({ kind }) => kind === "hold")))).toEqual(
|
|
[],
|
|
);
|
|
await host.close(context);
|
|
});
|
|
});
|