From 9616383e19fedd18b0f872d9275b30df4c287d15 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Sat, 14 Mar 2026 00:32:34 +0100 Subject: [PATCH] Add generic E2E harness and Marmot TS relay test suite --- .gitignore | 2 +- README.md | 6 + mix.exs | 4 +- scripts/run_e2e_suite.sh | 71 +++ scripts/run_marmot_e2e.sh | 18 + scripts/run_nak_e2e.sh | 50 +- test/marmot_e2e/marmot_client_e2e.test.mjs | 605 +++++++++++++++++++++ 7 files changed, 706 insertions(+), 50 deletions(-) create mode 100755 scripts/run_e2e_suite.sh create mode 100755 scripts/run_marmot_e2e.sh create mode 100644 test/marmot_e2e/marmot_client_e2e.test.mjs diff --git a/.gitignore b/.gitignore index 20b11f6..7154081 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ erl_crash.dump # Temporary files, for example, from tests. /tmp/ -/.nak-e2e-server.log +/.*-e2e-server.log # draw.io temp files .$* diff --git a/README.md b/README.md index 05c0a43..86cf783 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,9 @@ For external CLI end-to-end checks with `nak`: ```bash mix test.nak_e2e ``` + +For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`): + +```bash +mix test.marmot_e2e +``` diff --git a/mix.exs b/mix.exs index a6e0845..15f9ba9 100644 --- a/mix.exs +++ b/mix.exs @@ -61,6 +61,7 @@ defmodule Parrhesia.MixProject do "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "test.nak_e2e": ["cmd ./scripts/run_nak_e2e.sh"], + "test.marmot_e2e": ["cmd ./scripts/run_marmot_e2e.sh"], # cov: ["cmd mix coveralls.lcov"], lint: ["format --check-formatted", "credo"], precommit: [ @@ -69,7 +70,8 @@ defmodule Parrhesia.MixProject do "credo --strict --all", "deps.unlock --unused", "test", - "test.nak_e2e" + "test.nak_e2e", + "test.marmot_e2e" ] ] end diff --git a/scripts/run_e2e_suite.sh b/scripts/run_e2e_suite.sh new file mode 100755 index 0000000..12abee7 --- /dev/null +++ b/scripts/run_e2e_suite.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "usage: $0 [args...]" >&2 + exit 1 +fi + +SUITE_NAME="$1" +shift + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +export MIX_ENV=test + +SUITE_SLUG="$(printf '%s' "$SUITE_NAME" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '_')" +SUITE_UPPER="$(printf '%s' "$SUITE_SLUG" | tr '[:lower:]' '[:upper:]')" +PORT_ENV_VAR="PARRHESIA_${SUITE_UPPER}_E2E_RELAY_PORT" + +DEFAULT_PORT="$(( (RANDOM % 10000) + 40000 ))" +TEST_HTTP_PORT="${!PORT_ENV_VAR:-${PARRHESIA_E2E_RELAY_PORT:-$DEFAULT_PORT}}" + +export PARRHESIA_E2E_RELAY_PORT="$TEST_HTTP_PORT" +printf -v "$PORT_ENV_VAR" '%s' "$TEST_HTTP_PORT" +export "$PORT_ENV_VAR" + +if [[ -z "${PGDATABASE:-}" ]]; then + export PGDATABASE="parrhesia_${SUITE_SLUG}_test" +fi + +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.drop --quiet || true +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.create --quiet +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.migrate --quiet + +SERVER_LOG="${ROOT_DIR}/.${SUITE_SLUG}-e2e-server.log" +: > "$SERVER_LOG" + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +if ss -ltn "( sport = :${TEST_HTTP_PORT} )" | tail -n +2 | grep -q .; then + echo "Port ${TEST_HTTP_PORT} is already in use. Set ${PORT_ENV_VAR} to a free port." >&2 + exit 1 +fi + +PARRHESIA_TEST_HTTP_PORT="$TEST_HTTP_PORT" mix run --no-halt >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +READY=0 +for _ in {1..100}; do + if curl -fsS "http://127.0.0.1:${TEST_HTTP_PORT}/health" >/dev/null 2>&1; then + READY=1 + break + fi + sleep 0.1 +done + +if [[ "$READY" -ne 1 ]]; then + echo "Server did not become ready on port ${TEST_HTTP_PORT}" >&2 + tail -n 200 "$SERVER_LOG" >&2 || true + exit 1 +fi + +PARRHESIA_TEST_HTTP_PORT=0 "$@" diff --git a/scripts/run_marmot_e2e.sh b/scripts/run_marmot_e2e.sh new file mode 100755 index 0000000..6809698 --- /dev/null +++ b/scripts/run_marmot_e2e.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR/marmot-ts" + +if [[ ! -d node_modules ]]; then + npm install --no-audit --no-fund +fi + +npm run compile + +cd "$ROOT_DIR" +export PARRHESIA_MARMOT_E2E=1 + +exec ./scripts/run_e2e_suite.sh \ + marmot \ + node --test ./test/marmot_e2e/marmot_client_e2e.test.mjs diff --git a/scripts/run_nak_e2e.sh b/scripts/run_nak_e2e.sh index 298330d..5c8a590 100755 --- a/scripts/run_nak_e2e.sh +++ b/scripts/run_nak_e2e.sh @@ -4,54 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -export MIX_ENV=test export PARRHESIA_NAK_E2E=1 -TEST_HTTP_PORT="${PARRHESIA_NAK_E2E_RELAY_PORT:-$(( (RANDOM % 10000) + 40000 ))}" - -if [[ -z "${PGDATABASE:-}" ]]; then - export PGDATABASE="parrhesia_nak_e2e_test" -fi - -PARRHESIA_TEST_HTTP_PORT=0 mix ecto.drop --quiet || true -PARRHESIA_TEST_HTTP_PORT=0 mix ecto.create --quiet -PARRHESIA_TEST_HTTP_PORT=0 mix ecto.migrate --quiet - -SERVER_LOG="${ROOT_DIR}/.nak-e2e-server.log" -: > "$SERVER_LOG" - -cleanup() { - if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi -} - -trap cleanup EXIT INT TERM - -if ss -ltn "( sport = :${TEST_HTTP_PORT} )" | tail -n +2 | grep -q .; then - echo "Port ${TEST_HTTP_PORT} is already in use. Set PARRHESIA_NAK_E2E_RELAY_PORT to a free port." >&2 - exit 1 -fi - -PARRHESIA_TEST_HTTP_PORT="$TEST_HTTP_PORT" mix run --no-halt >"$SERVER_LOG" 2>&1 & -SERVER_PID=$! - -READY=0 -for _ in {1..100}; do - if curl -fsS "http://127.0.0.1:${TEST_HTTP_PORT}/health" >/dev/null 2>&1; then - READY=1 - break - fi - sleep 0.1 -done - -if [[ "$READY" -ne 1 ]]; then - echo "Server did not become ready on port ${TEST_HTTP_PORT}" >&2 - tail -n 200 "$SERVER_LOG" >&2 || true - exit 1 -fi - -PARRHESIA_TEST_HTTP_PORT=0 \ - PARRHESIA_NAK_E2E_RELAY_PORT="$TEST_HTTP_PORT" \ +exec ./scripts/run_e2e_suite.sh \ + nak \ mix test test/parrhesia/e2e/nak_cli_test.exs --no-start --only nak_e2e --timeout 15000 diff --git a/test/marmot_e2e/marmot_client_e2e.test.mjs b/test/marmot_e2e/marmot_client_e2e.test.mjs new file mode 100644 index 0000000..1af0eab --- /dev/null +++ b/test/marmot_e2e/marmot_client_e2e.test.mjs @@ -0,0 +1,605 @@ +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import test from "node:test"; + +import { + InviteReader, + KEY_PACKAGE_KIND, + KEY_PACKAGE_RELAY_LIST_KIND, + GROUP_EVENT_KIND, + WELCOME_EVENT_KIND, + MarmotClient, + KeyPackageStore, + KeyValueGroupStateBackend, + createKeyPackageRelayListEvent, + deserializeApplicationData, + extractMarmotGroupData, + getKeyPackageRelayList, +} from "../../marmot-ts/dist/index.js"; +import { PrivateKeyAccount } from "../../marmot-ts/node_modules/applesauce-accounts/dist/accounts/index.js"; + +const GIFT_WRAP_KIND = 1059; + +const relayPort = + process.env.PARRHESIA_MARMOT_E2E_RELAY_PORT ?? + process.env.PARRHESIA_E2E_RELAY_PORT ?? + "4051"; +const relayHttpBase = `http://127.0.0.1:${relayPort}`; +const relayHttpUrl = `${relayHttpBase}/relay`; +const relayWsUrl = relayHttpUrl.replace("http://", "ws://"); + +class MemoryBackend { + #map = new Map(); + + async getItem(key) { + return this.#map.get(key) ?? null; + } + + async setItem(key, value) { + this.#map.set(key, value); + return value; + } + + async removeItem(key) { + this.#map.delete(key); + } + + async clear() { + this.#map.clear(); + } + + async keys() { + return Array.from(this.#map.keys()); + } +} + +class RelayNetwork { + constructor(relayWs, relayHttp) { + this.relayWs = relayWs; + this.relayHttp = relayHttp; + } + + async publish(relays, event) { + const responses = await Promise.all( + relays.map(async (relay) => { + const frame = await publishEvent(relay, event); + return [relay, { from: relay, ok: Boolean(frame[2]), message: frame[3] }]; + }), + ); + + return Object.fromEntries(responses); + } + + async request(relays, filters) { + const filtersArray = Array.isArray(filters) ? filters : [filters]; + + const eventsById = new Map(); + + for (const relay of relays) { + const events = await requestEvents(relay, filtersArray); + for (const event of events) { + eventsById.set(event.id, event); + } + } + + return Array.from(eventsById.values()); + } + + subscription(relays, filters) { + return { + subscribe: (observer) => { + let unsubscribed = false; + + void this.request(relays, filters) + .then((events) => { + for (const event of events) { + if (unsubscribed) { + return; + } + + observer.next?.(event); + } + + if (!unsubscribed) { + observer.complete?.(); + } + }) + .catch((error) => { + if (!unsubscribed) { + observer.error?.(error); + } + }); + + return { + unsubscribe: () => { + unsubscribed = true; + }, + }; + }, + }; + } + + async getUserInboxRelays(pubkey) { + const relayListEvents = await this.request([this.relayWs], [ + { kinds: [KEY_PACKAGE_RELAY_LIST_KIND], authors: [pubkey], limit: 1 }, + ]); + + if (relayListEvents.length === 0) { + return [this.relayWs]; + } + + const relays = getKeyPackageRelayList(relayListEvents[0]); + return relays.length > 0 ? relays : [this.relayWs]; + } +} + +function createClient(account, network) { + return new MarmotClient({ + signer: account.signer, + network, + keyPackageStore: new KeyPackageStore(new MemoryBackend()), + groupStateBackend: new KeyValueGroupStateBackend(new MemoryBackend()), + }); +} + +function computeEventId(event) { + const payload = [ + 0, + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content, + ]; + + return createHash("sha256").update(JSON.stringify(payload)).digest("hex"); +} + +function bytesToHex(bytes) { + return Buffer.from(bytes).toString("hex"); +} + +function unixNow() { + return Math.floor(Date.now() / 1000); +} + +function randomSubId(prefix) { + return `${prefix}-${randomUUID()}`; +} + +async function openRelay(relayUrl, timeoutMs = 5_000) { + const ws = new WebSocket(relayUrl); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`WebSocket open timeout for ${relayUrl}`)); + }, timeoutMs); + + ws.addEventListener( + "open", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + + ws.addEventListener( + "error", + (error) => { + clearTimeout(timeout); + reject(error.error ?? error); + }, + { once: true }, + ); + }); + + const queue = []; + let waiter = null; + + ws.addEventListener("message", (message) => { + let frame; + + try { + frame = JSON.parse(String(message.data)); + } catch { + return; + } + + if (waiter) { + const activeWaiter = waiter; + waiter = null; + activeWaiter.resolve(frame); + } else { + queue.push(frame); + } + }); + + ws.addEventListener("close", () => { + if (waiter) { + const activeWaiter = waiter; + waiter = null; + activeWaiter.reject(new Error(`WebSocket closed for ${relayUrl}`)); + } + }); + + return { + send(frame) { + ws.send(JSON.stringify(frame)); + }, + + nextFrame(timeoutMs = 5_000) { + if (queue.length > 0) { + return Promise.resolve(queue.shift()); + } + + if (waiter) { + return Promise.reject(new Error("Only one pending frame wait is supported")); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (waiter) { + waiter = null; + } + reject(new Error(`Timed out waiting for relay frame from ${relayUrl}`)); + }, timeoutMs); + + waiter = { + resolve: (frame) => { + clearTimeout(timeout); + resolve(frame); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }; + }); + }, + + async close() { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + }, + }; +} + +async function publishEvent(relayUrl, event, timeoutMs = 5_000) { + const relay = await openRelay(relayUrl, timeoutMs); + + try { + relay.send(["EVENT", event]); + + while (true) { + const frame = await relay.nextFrame(timeoutMs); + if (Array.isArray(frame) && frame[0] === "OK" && frame[1] === event.id) { + return frame; + } + } + } finally { + await relay.close(); + } +} + +async function requestEvents(relayUrl, filters, timeoutMs = 5_000) { + const relay = await openRelay(relayUrl, timeoutMs); + const subscriptionId = randomSubId("req"); + const events = []; + + try { + relay.send(["REQ", subscriptionId, ...filters]); + + while (true) { + const frame = await relay.nextFrame(timeoutMs); + if (!Array.isArray(frame)) { + continue; + } + + const [type, subId, payload] = frame; + + if (type === "EVENT" && subId === subscriptionId) { + events.push(payload); + } + + if (type === "EOSE" && subId === subscriptionId) { + break; + } + + if (type === "CLOSED" && subId === subscriptionId) { + throw new Error(`relay closed request ${subscriptionId}: ${payload}`); + } + } + + relay.send(["CLOSE", subscriptionId]); + return events; + } finally { + await relay.close(); + } +} + +async function requestGiftWrapsWithAuth({ relayUrl, relayHttpUrl, signer, recipientPubkey }) { + const relay = await openRelay(relayUrl, 5_000); + const probeSubId = randomSubId("auth-probe"); + + try { + relay.send(["REQ", probeSubId, { kinds: [GIFT_WRAP_KIND], "#p": [recipientPubkey], limit: 1 }]); + + let challenge = null; + + for (;;) { + const frame = await relay.nextFrame(5_000); + if (!Array.isArray(frame)) { + continue; + } + + if (frame[0] === "AUTH" && typeof frame[1] === "string") { + challenge = frame[1]; + } + + if (frame[0] === "CLOSED" && frame[1] === probeSubId) { + break; + } + } + + assert.ok(challenge, "relay should provide an AUTH challenge for restricted giftwrap queries"); + + const authEvent = await signer.signEvent({ + kind: 22_242, + created_at: unixNow(), + tags: [ + ["challenge", challenge], + ["relay", relayHttpUrl], + ], + content: "", + }); + + relay.send(["AUTH", authEvent]); + + for (;;) { + const frame = await relay.nextFrame(5_000); + if (!Array.isArray(frame)) { + continue; + } + + if (frame[0] === "OK" && frame[1] === authEvent.id) { + assert.equal(frame[2], true, `auth rejected: ${frame[3] ?? "unknown error"}`); + break; + } + } + + const subscriptionId = randomSubId("giftwrap"); + const giftWraps = []; + + relay.send([ + "REQ", + subscriptionId, + { kinds: [GIFT_WRAP_KIND], "#p": [recipientPubkey], limit: 20 }, + ]); + + for (;;) { + const frame = await relay.nextFrame(5_000); + if (!Array.isArray(frame)) { + continue; + } + + const [type, subId, payload] = frame; + + if (type === "EVENT" && subId === subscriptionId) { + giftWraps.push(payload); + } + + if (type === "EOSE" && subId === subscriptionId) { + break; + } + + if (type === "CLOSED" && subId === subscriptionId) { + throw new Error(`giftwrap request closed unexpectedly: ${payload}`); + } + } + + relay.send(["CLOSE", subscriptionId]); + return giftWraps; + } finally { + await relay.close(); + } +} + +test("relay returns NIP-11 metadata and advertises Marmot support", async () => { + const response = await fetch(relayHttpUrl, { + headers: { Accept: "application/nostr+json" }, + }); + + assert.equal(response.status, 200); + + const body = await response.json(); + assert.equal(body.name, "Parrhesia"); + assert.ok(body.supported_nips.includes(59)); + assert.ok(body.supported_nips.includes(77)); +}); + +test("key package create + rotate works against relay storage", async () => { + const account = PrivateKeyAccount.generateNew(); + const pubkey = await account.signer.getPublicKey(); + const network = new RelayNetwork(relayWsUrl, relayHttpUrl); + const client = createClient(account, network); + + const relayListEvent = await account.signer.signEvent( + createKeyPackageRelayListEvent({ + pubkey, + relays: [relayWsUrl], + client: "parrhesia-marmot-e2e", + }), + ); + + await network.publish([relayWsUrl], relayListEvent); + + const keyPackage = await client.keyPackages.create({ + relays: [relayWsUrl], + client: "parrhesia-marmot-e2e", + }); + + const keyPackageRef = bytesToHex(keyPackage.keyPackageRef); + + const keyPackageEvents = await network.request([relayWsUrl], [ + { kinds: [KEY_PACKAGE_KIND], authors: [pubkey], limit: 10 }, + ]); + + assert.ok(keyPackageEvents.length >= 1); + + const matchingByRef = await network.request([relayWsUrl], [ + { kinds: [KEY_PACKAGE_KIND], "#i": [keyPackageRef], limit: 10 }, + ]); + + assert.ok(matchingByRef.length >= 1); + + await client.keyPackages.rotate(keyPackage.keyPackageRef, { relays: [relayWsUrl] }); + + const afterRotateOldRef = await network.request([relayWsUrl], [ + { kinds: [KEY_PACKAGE_KIND], "#i": [keyPackageRef], limit: 10 }, + ]); + + assert.equal(afterRotateOldRef.length, 0); + + +}); + +test("kind 445 requests without #h are rejected by relay policy", async () => { + const relay = await openRelay(relayWsUrl, 5_000); + + try { + const subscriptionId = randomSubId("missing-h"); + relay.send(["REQ", subscriptionId, { kinds: [GROUP_EVENT_KIND], limit: 5 }]); + + for (;;) { + const frame = await relay.nextFrame(5_000); + if (!Array.isArray(frame)) { + continue; + } + + if (frame[0] === "CLOSED" && frame[1] === subscriptionId) { + assert.match(String(frame[2]), /kind 445 queries must include a #h tag/); + break; + } + } + } finally { + await relay.close(); + } +}); + +test("admin invites user, user joins, and message round-trip decrypts", async () => { + const network = new RelayNetwork(relayWsUrl, relayHttpUrl); + + const adminAccount = PrivateKeyAccount.generateNew(); + const inviteeAccount = PrivateKeyAccount.generateNew(); + + const adminPubkey = await adminAccount.signer.getPublicKey(); + const inviteePubkey = await inviteeAccount.signer.getPublicKey(); + + const adminClient = createClient(adminAccount, network); + const inviteeClient = createClient(inviteeAccount, network); + + const adminRelayList = await adminAccount.signer.signEvent( + createKeyPackageRelayListEvent({ + pubkey: adminPubkey, + relays: [relayWsUrl], + client: "parrhesia-marmot-e2e", + }), + ); + + const inviteeRelayList = await inviteeAccount.signer.signEvent( + createKeyPackageRelayListEvent({ + pubkey: inviteePubkey, + relays: [relayWsUrl], + client: "parrhesia-marmot-e2e", + }), + ); + + await network.publish([relayWsUrl], adminRelayList); + await network.publish([relayWsUrl], inviteeRelayList); + + await inviteeClient.keyPackages.create({ + relays: [relayWsUrl], + client: "parrhesia-marmot-e2e", + }); + + const adminGroup = await adminClient.createGroup(`Parrhesia Group ${randomUUID()}`, { + relays: [relayWsUrl], + adminPubkeys: [adminPubkey], + }); + + const inviteeKeyPackageEvents = await network.request([relayWsUrl], [ + { kinds: [KEY_PACKAGE_KIND], authors: [inviteePubkey], limit: 1 }, + ]); + + assert.equal(inviteeKeyPackageEvents.length, 1); + + await adminGroup.inviteByKeyPackageEvent(inviteeKeyPackageEvents[0]); + + const marmotGroupData = extractMarmotGroupData(adminGroup.state); + assert.ok(marmotGroupData, "group should include Marmot extension data"); + + const nostrGroupId = bytesToHex(marmotGroupData.nostrGroupId); + + const commitEvents = await network.request([relayWsUrl], [ + { kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 10 }, + ]); + assert.ok(commitEvents.length >= 1); + + const giftWraps = await requestGiftWrapsWithAuth({ + relayUrl: relayWsUrl, + relayHttpUrl, + signer: inviteeAccount.signer, + recipientPubkey: inviteePubkey, + }); + + assert.equal(giftWraps.length, 1); + + const inviteReader = new InviteReader({ + signer: inviteeAccount.signer, + store: { + received: new MemoryBackend(), + unread: new MemoryBackend(), + seen: new MemoryBackend(), + }, + }); + + await inviteReader.ingestEvents(giftWraps); + const invites = await inviteReader.decryptGiftWraps(); + + assert.equal(invites.length, 1); + assert.equal(invites[0].kind, WELCOME_EVENT_KIND); + + const { group: inviteeGroup } = await inviteeClient.joinGroupFromWelcome({ + welcomeRumor: invites[0], + }); + + const messageRumor = { + kind: 9, + pubkey: inviteePubkey, + created_at: unixNow(), + tags: [], + content: `hello-from-invitee-${randomUUID()}`, + }; + messageRumor.id = computeEventId(messageRumor); + + await inviteeGroup.sendApplicationRumor(messageRumor); + + const groupEventsAfterMessage = await network.request([relayWsUrl], [ + { kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 50 }, + ]); + + const decryptedMessages = []; + + for await (const result of adminGroup.ingest(groupEventsAfterMessage)) { + if (result.kind === "processed" && result.result.kind === "applicationMessage") { + decryptedMessages.push(deserializeApplicationData(result.result.message)); + } + } + + assert.ok( + decryptedMessages.some((rumor) => rumor.content === messageRumor.content), + "admin should decrypt invitee application message", + ); +});