diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 85f8297..1c5dbb9 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -86,6 +86,17 @@ class FakeConnection { }; } + /** True once this connection's own close() has actually been invoked -- lets a test assert that a caller closed the link, distinct from the link merely ending its receive stream on its own (see endStream). */ + get isClosed(): boolean { + return this.ended; + } + + /** Ends the receive stream as if the remote hung up cleanly, without going through this side's own close() -- unlike close(), this leaves the session's own state untouched so a test can observe how the session itself reacts to a graceful remote end. */ + endStream(): void { + this.ended = true; + this.wake(); + } + push(frame: Frame): void { this.inbound.push(frame); this.wake(); @@ -167,6 +178,31 @@ function multiConnectionTransport(): { return { transport, connections }; } +/** Narrows an IteratorResult to its yielded value, failing the test outright if the iterator has actually ended -- none of this file's own async iterators ever end, so a done:true result always indicates a broken assumption in the test itself, never legitimate data. */ +function yielded(result: IteratorResult): T { + if (result.done === true) { + throw new Error("expected the iterator to yield a value, got done: true"); + } + return result.value; +} + +const TIMEOUT_MARKER = "timeout" as const; +const SHORT_WAIT_MS = 20; + +/** Races a promise against a short real-time wait, resolving to TIMEOUT_MARKER if the promise hasn't settled yet -- used to prove a promise genuinely settled *now*, from the action just taken, rather than merely settling *eventually* by some unrelated later event (e.g. a trailing session.close() emitting one final event that would otherwise silently satisfy an unconsumed waiter and mask a missing emit() call). */ +async function withinShortWait( + promise: Readonly>, +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + resolve(TIMEOUT_MARKER); + }, SHORT_WAIT_MS); + }), + ]); +} + /** Resolves after the session has emitted at least `count` events, returning the latest. */ async function nthEvent( session: ReturnType, @@ -314,6 +350,50 @@ describe("createMeshSession", () => { await session.close(); }); + it("sendGossipUpdate rejects an extension key that collides with the mandatory addresses field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect(session.sendGossipUpdate({ addresses: [] })).rejects.toThrow( + /collides with a mandatory peer-advert field/, + ); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that collides with the mandatory snapshot-seconds field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "snapshot-seconds": 0 }), + ).rejects.toThrow(/collides with a mandatory peer-advert field/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key with a domain-qualified prefix but trailing garbage after it", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "presence/status!": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that only matches a domain-qualified pattern partway through the string", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "1presence/status": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -335,6 +415,43 @@ describe("createMeshSession", () => { await session.close(); }); + it("emits a session event after sending a gossip update", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/data"]); + await eventsDone; + + const eventsIterator = session.events[Symbol.asyncIterator](); + await session.sendGossipUpdate(); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendGossipUpdate. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(event.frameLog.at(-1)?.direction).toBe("sent"); + expect(event.frameLog.at(-1)?.frame.type).toBe("gossip"); + await session.close(); + }); + + it("refuses sendGossipUpdate while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + + it("refuses sendGossipUpdate once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + it("excludes the retired core/federation domain even when both sides offer it", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -442,6 +559,190 @@ describe("createMeshSession", () => { const session = createMeshSession(transport, testIdentity, testClock); await expect(session.sendPing()).rejects.toThrow("not connected"); }); + + it("refuses a ping once the connection has failed and the session is closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendPing()).rejects.toThrow("not connected"); + }); + + it("close() while connected finalizes state as closed by you, and actually closes the underlying connection", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + await session.connect("ws://node", ["core/data"]); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + expect(connection.isClosed).toBe(true); + }); + + it("closes a dial that only completes after close() was already called, instead of wiring it up", async () => { + // A plain `let` reassigned only inside the Promise executor below loses its non-null narrowing by the time it's called several `await`s later -- an object property isn't narrowed the same way a bare closed-over variable is, so this sidesteps that entirely. + const dialResolver: { + resolve: ((connection: Connection) => void) | null; + } = { resolve: null }; + const transport: Transport = { + connect: async (): Promise => + new Promise((resolve) => { + dialResolver.resolve = resolve; + }), + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock); + const eventsIterator = session.events[Symbol.asyncIterator](); + const connectPromise = session.connect("ws://node", ["core/data"]); + await eventsIterator.next(); // connecting + await session.close(); + const lateConnection = new FakeConnection(); + if (dialResolver.resolve === null) + throw new Error("expected resolveDial to be set"); + dialResolver.resolve(lateConnection.connection); + await connectPromise; + expect(lateConnection.sent).toHaveLength(0); + expect(lateConnection.isClosed).toBe(true); + const closedEvent = yielded(await eventsIterator.next()) as { + state: { status: string; reason?: string }; + }; + expect(closedEvent.state.status).toBe("closed"); + expect((closedEvent.state as { reason: string }).reason).toBe( + "closed by you", + ); + }); + + it("treats a clean end of the receive stream as a disconnect when still connected", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + connection.endStream(); + const event = (await eventsDone) as { + state: { status: string; reason: string }; + }; + expect(event.state.status).toBe("closed"); + expect(event.state.reason).toBe("node closed the connection"); + }); + + it("ignores a frame that was already queued when close() is called, instead of applying it", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + await session.connect("ws://node", ["core/data"]); + connection.push(gossipFor(deviceA)); + await session.close(); + for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { + await iterator.next(); + } + const result = await withinShortWait(iterator.next()); + expect(result).toBe(TIMEOUT_MARKER); + }); + + it("does not negotiate against a second handshake frame once the first has already settled the outcome", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/management", "core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data", "core/exec"], + } satisfies HandshakeFrame); + await eventsDone; + + const secondEventDone = nthEvent(session, 1); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + } satisfies HandshakeFrame); + const event = (await secondEventDone) as { + state: { + status: string; + handshake: { status: string; sharedDomains: string[] }; + }; + }; + expect(event.state.handshake.status).toBe("negotiated"); + expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); + await session.close(); + }); + + it("keeps the handshake negotiated after HANDSHAKE_TIMEOUT_MS elapses, instead of flipping to unanswered", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data"], + } satisfies HandshakeFrame); + await eventsDone; + + await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); + await session.sendPing(); + const lastFrame = connection.sent.at(-1) as { type: string }; + expect(lastFrame.type).toBe("ping"); + // sendPing itself would have thrown if state had reverted away from "connected", and negotiate() already proved the handshake status. A direct re-check below confirms the handshake status specifically stayed "negotiated". + const stillConnectedEvent = (await nthEvent(session, 1)) as { + state: { handshake?: { status: string } }; + }; + expect(stillConnectedEvent.state.handshake?.status).toBe("negotiated"); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("records a specific reason when the handshake is rejected for sharing no domains or version", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/federation"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + }); + const event = (await eventsDone) as { + state: { handshake: { status: string; reason?: string } }; + }; + expect(event.state.handshake.status).toBe("rejected"); + expect((event.state.handshake as { reason: string }).reason).toBe( + "no shared domains or version", + ); + await session.close(); + }); + + it("events iterator resolves a live event, delivered after the wait began, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + // No event has been emitted yet, so this call registers a waiter rather than draining the backlog. + const pending = iterator.next(); + await session.connect("ws://node", ["core/data"]); + const result = await pending; + expect(result.done).toBe(false); + await session.close(); + }); + + it("events iterator resolves a backlogged event, queued before anyone was iterating, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const iterator = session.events[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + await session.close(); + }); }); describe("reconnect policy", () => { @@ -527,6 +828,117 @@ describe("reconnect policy", () => { vi.useRealTimers(); } }); + + it("cancels the handshake timeout when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending scheduled reconnect when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + expect(connections).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("finalizes as closed by you when close() is called while a reconnect is pending", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + const reconnecting = (await nthEvent( + session, + EVENTS_THROUGH_FIRST_RECONNECT, + )) as { state: { status: string } }; + expect(reconnecting.state.status).toBe("reconnecting"); + const eventsDone = nthEvent(session, 1); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + } finally { + vi.useRealTimers(); + } + }); + + it("treats a failed retry dial itself as a disconnect, not a silently swallowed error", async () => { + vi.useFakeTimers(); + try { + const connections: FakeConnection[] = []; + let calls = 0; + const transport: Transport = { + connect: async (address: string): Promise => { + calls += 1; + if (address !== "ws://node") { + return Promise.reject(new Error(`connect to ${address} failed`)); + } + if (calls > 1) { + return Promise.reject(new Error("dial failed on retry")); + } + const next = new FakeConnection(); + connections.push(next); + return Promise.resolve(next.connection); + }, + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FIRST_RECONNECT); + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + + const iterator = session.events[Symbol.asyncIterator](); + let event: { state: { status: string; reason?: string } } | null = null; + const MAX_EVENTS_TO_SCAN = 10; + for (let i = 0; i < MAX_EVENTS_TO_SCAN; i++) { + const result = (await iterator.next()) as { + value: { state: { status: string; reason?: string } }; + }; + event = result.value; + if (event.state.status === "closed") { + break; + } + } + expect(event?.state.status).toBe("closed"); + expect(event?.state.reason).toBe("dial failed on retry"); + } finally { + vi.useRealTimers(); + } + }); }); const TEST_TOKEN_SIGNATURE_BYTE = 3; @@ -630,6 +1042,66 @@ describe("capability tokens and manage-request plumbing", () => { await expect(usingSessionDefault).rejects.toThrow(); }); + it("assigns sequentially increasing request-ids to successive sendManageRequest calls", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const first = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const firstId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + const second = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const secondId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + expect(secondId).toBe(firstId + 1); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + }); + + it("refuses sendManageRequest while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("refuses sendManageRequest once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("never times out a request when no timeoutMs is given", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await vi.advanceTimersByTimeAsync(1); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + connection.push({ + type: "manage-response", + "request-id": sentRequest["request-id"], + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await expect(pending).resolves.toEqual({ result: "ok" }); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("resolves sendManageRequest only with the outcome of the matching manage-response", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -733,12 +1205,15 @@ describe("capability tokens and manage-request plumbing", () => { it("surfaces an incoming manage-request on incomingManageRequests, and sends the response frame from respond()", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); await session.connect("ws://node", ["core/management"]); + await eventsDone; - const incomingDone = (async (): Promise => { + const incomingDone = (async (): Promise< + IteratorResult + > => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); - const result = await iterator.next(); - return result.value as IncomingManageRequest; + return iterator.next(); })(); connection.push({ @@ -749,12 +1224,18 @@ describe("capability tokens and manage-request plumbing", () => { token: testToken, } satisfies ManageRequestFrame); - const incoming = await incomingDone; + const incomingResult = await incomingDone; + expect(incomingResult.done).toBe(false); + const incoming = yielded(incomingResult); expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); expect(incoming.command).toEqual(testCommand); expect(incoming.scope).toEqual(testScope); expect(incoming.token).toEqual(testToken); + // Consume the "received manage-request" event that is already backlogged. + const eventsIterator = session.events[Symbol.asyncIterator](); + await eventsIterator.next(); + await incoming.respond({ result: "ok" }); const sentResponse = connection.sent.at(-1) as ManageResponseFrame; expect(sentResponse).toEqual({ @@ -762,6 +1243,41 @@ describe("capability tokens and manage-request plumbing", () => { "request-id": TEST_INCOMING_REQUEST_ID, outcome: { result: "ok" }, } satisfies ManageResponseFrame); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call inside respond(). + const responseResult = await withinShortWait(eventsIterator.next()); + expect(responseResult).not.toBe(TIMEOUT_MARKER); + const responseEvent = yielded( + responseResult as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(responseEvent.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: sentResponse, + }); + await session.close(); + }); + + it("delivers a manage-request queued before anyone was iterating incomingManageRequests, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + } satisfies ManageRequestFrame); + await nextEventDone; + + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result).requestId).toBe(TEST_INCOMING_REQUEST_ID); await session.close(); }); }); @@ -797,6 +1313,52 @@ describe("revocation-announce plumbing", () => { await session.close(); }); + it("emits a session event after sending a revocation-announce", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const eventsIterator = session.events[Symbol.asyncIterator](); + await session.sendRevocationAnnounce([testEntryA]); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendRevocationAnnounce. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(event.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: { + type: "revocation-announce", + entries: [testEntryA], + }, + }); + await session.close(); + }); + + it("refuses sendRevocationAnnounce while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + + it("refuses sendRevocationAnnounce once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + it("flattens an incoming revocation-announce frame's entries onto revocationAnnouncements, one item per entry", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -805,8 +1367,12 @@ describe("revocation-announce plumbing", () => { const received: RevocationEntry[] = []; const receivedBoth = (async (): Promise => { const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); - received.push((await iterator.next()).value as RevocationEntry); - received.push((await iterator.next()).value as RevocationEntry); + const first = await iterator.next(); + expect(first.done).toBe(false); + received.push(yielded(first)); + const second = await iterator.next(); + expect(second.done).toBe(false); + received.push(yielded(second)); })(); connection.push({ @@ -818,6 +1384,27 @@ describe("revocation-announce plumbing", () => { expect(received).toEqual([testEntryA, testEntryB]); await session.close(); }); + + it("delivers a revocation entry queued before anyone was iterating revocationAnnouncements, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "revocation-announce", + entries: [testEntryA], + } satisfies RevocationAnnounceFrame); + await nextEventDone; + + const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result)).toEqual(testEntryA); + await session.close(); + }); }); describe("relay routing", () => { @@ -1075,6 +1662,32 @@ describe("acceptMeshSession", () => { await session.close(); }); + it("advertises no addresses in its self-advert when none are given, rather than a stray default", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + + const selfAdvert = fake.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.addresses).toEqual([]); + await session.close(); + }); + + it("labels its connection state 'accepted' when no label is given", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession( + fake.connection, + testIdentity, + ["core/data"], + { clock: testClock }, + ); + const event = (await nthEvent(session, 1)) as { + state: { address: string }; + }; + expect(event.state.address).toBe("accepted"); + await session.close(); + }); + it("negotiates against the remote's own handshake exactly like the dial side", async () => { const fake = new FakeConnection(); const session = await acceptMeshSession(fake.connection, testIdentity, [ @@ -1127,7 +1740,9 @@ describe("acceptMeshSession", () => { const session = await acceptMeshSession(fake.connection, testIdentity, [ "core/data", ]); - await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow(); + await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( + "connects once", + ); await session.close(); });