@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import {
18
18
  afterAll,
19
+ beforeAll,
19
20
  beforeEach,
20
21
  describe,
21
22
  expect,
@@ -25,6 +26,10 @@ import {
25
26
  test,
26
27
  } from "bun:test";
27
28
 
29
+ import { ADMISSION_FLOOR } from "@vellumai/gateway-client";
30
+
31
+ import { TRUST_CLASS_RANK } from "../runtime/actor-trust-resolver.js";
32
+
28
33
  // ── Platform + logger mocks (must come before any source imports) ────
29
34
 
30
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -100,6 +105,80 @@ mock.module("../config/loader.js", () => ({
100
105
  loadConfig: () => mockConfig,
101
106
  }));
102
107
 
108
+ // ── Channel admission policy reader mock ────────────────────────────
109
+ //
110
+ // handleSetup resolves the phone admission floor via this reader and forwards
111
+ // it into routeSetup. Tests drive the floor by setting `mockAdmissionPolicy`
112
+ // (or making the reader throw to exercise the fail-open guard).
113
+
114
+ let mockAdmissionPolicy:
115
+ | import("@vellumai/gateway-client").AdmissionPolicy
116
+ | null = null;
117
+ // Optional gate to hold the reader open mid-setup so tests can drive the
118
+ // race window where a prompt arrives while handleSetup is still awaiting.
119
+ let mockAdmissionGate: Promise<void> | null = null;
120
+ // `mock.module` is process-global in Bun and leaks into sibling files that run
121
+ // later in the same `bun test` invocation. channel-admission-reader.test.ts
122
+ // imports the reader for real, so an unconditional stub here would feed it this
123
+ // stub and break its assertions. Delegate to the real implementation unless
124
+ // this file's tests are active (`admissionMockActive`, toggled in
125
+ // beforeAll/afterAll). Snapshot the real exports into a plain object NOW,
126
+ // before the stub registers — a module namespace is a live view, so reading
127
+ // the real export after the stub installs would resolve back to the stub
128
+ // (infinite recursion).
129
+ const realChannelAdmissionReaderModule = {
130
+ ...(await import("../calls/channel-admission-reader.js")),
131
+ };
132
+ let admissionMockActive = false;
133
+ mock.module("../calls/channel-admission-reader.js", () => ({
134
+ ...realChannelAdmissionReaderModule,
135
+ getChannelAdmissionPolicy: async (
136
+ channelType: import("../channels/types.js").ChannelId,
137
+ ) => {
138
+ if (!admissionMockActive) {
139
+ return realChannelAdmissionReaderModule.getChannelAdmissionPolicy(
140
+ channelType,
141
+ );
142
+ }
143
+ if (mockAdmissionGate) await mockAdmissionGate;
144
+ return mockAdmissionPolicy;
145
+ },
146
+ }));
147
+
148
+ // Real floor semantics, mirroring relay-setup-router.test.ts. The
149
+ // enforceAdmissionPolicy mock omits the exempt-channel short-circuit so the
150
+ // deny path can be exercised end-to-end regardless of channel.
151
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
152
+ const realAdmissionPolicyModule = require("../runtime/routes/inbound-stages/admission-policy.js");
153
+ mock.module("../runtime/routes/inbound-stages/admission-policy.js", () => ({
154
+ ...realAdmissionPolicyModule,
155
+ enforceAdmissionPolicy: (input: {
156
+ trustClass: string;
157
+ memberStatus: string | undefined;
158
+ policy: import("@vellumai/gateway-client").AdmissionPolicy;
159
+ }) => {
160
+ if (input.memberStatus === "blocked" || input.memberStatus === "revoked") {
161
+ return {
162
+ admitted: false,
163
+ reason:
164
+ input.memberStatus === "blocked" ? "member_blocked" : "member_revoked",
165
+ shouldChallenge: false,
166
+ effectivePolicy: input.policy,
167
+ };
168
+ }
169
+ const rank =
170
+ (TRUST_CLASS_RANK as Record<string, number>)[input.trustClass] ?? 0;
171
+ const floor = ADMISSION_FLOOR[input.policy];
172
+ if (rank >= floor) return { admitted: true };
173
+ return {
174
+ admitted: false,
175
+ reason: `admission_policy_${input.policy}`,
176
+ shouldChallenge: false,
177
+ effectivePolicy: input.policy,
178
+ };
179
+ },
180
+ }));
181
+
103
182
  // ── TTS provider mocks (for call-speech-output) ─────────────────────
104
183
 
105
184
  let mockTtsProviderId: string = "elevenlabs";
@@ -240,7 +319,15 @@ import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
240
319
 
241
320
  initializeDb();
242
321
 
322
+ // Activate the channel-admission-reader stub only while this file's tests run,
323
+ // so the registered (process-global) mock delegates to the real module for
324
+ // sibling files that load later in the same worker.
325
+ beforeAll(() => {
326
+ admissionMockActive = true;
327
+ });
328
+
243
329
  afterAll(() => {
330
+ admissionMockActive = false;
244
331
  resetDbForTesting();
245
332
  });
246
333
 
@@ -404,6 +491,8 @@ describe("relay-server", () => {
404
491
  activeRelayConnections.clear();
405
492
  mockUserReference = "my human";
406
493
  mockAssistantName = "Vellum";
494
+ mockAdmissionPolicy = null;
495
+ mockAdmissionGate = null;
407
496
  mockSendMessage.mockImplementation(createMockProviderResponse(["Hello"]));
408
497
  mockConfig.calls.verification.enabled = false;
409
498
  mockConfig.calls.verification.maxAttempts = 3;
@@ -840,6 +929,121 @@ describe("relay-server", () => {
840
929
  relay.destroy();
841
930
  });
842
931
 
932
+ test("handleMessage: prompt arriving during setup (setting_up) is dropped", async () => {
933
+ ensureConversation("conv-relay-setting-up");
934
+ const session = createCallSession({
935
+ conversationId: "conv-relay-setting-up",
936
+ provider: "twilio",
937
+ fromNumber: "+15551111111",
938
+ toNumber: "+15552222222",
939
+ });
940
+
941
+ addTrustedVoiceContact("+15551111111");
942
+
943
+ const { ws, relay } = createMockWs(session.id);
944
+
945
+ // Hold the admission reader open so handleSetup stays in "setting_up".
946
+ let releaseGate: () => void = () => {};
947
+ mockAdmissionGate = new Promise<void>((resolve) => {
948
+ releaseGate = resolve;
949
+ });
950
+
951
+ // Fire setup without awaiting — it suspends inside getChannelAdmissionPolicy.
952
+ const setupPromise = relay.handleMessage(
953
+ JSON.stringify({
954
+ type: "setup",
955
+ callSid: "CA_setting_up_123",
956
+ from: "+15551111111",
957
+ to: "+15552222222",
958
+ }),
959
+ );
960
+
961
+ // Let the setup task reach the await; state must be "setting_up".
962
+ await new Promise((resolve) => setTimeout(resolve, 0));
963
+ expect(relay.getConnectionState()).toBe("setting_up");
964
+
965
+ // A prompt arriving now must be dropped: no transcript, no fallback.
966
+ await relay.handleMessage(
967
+ JSON.stringify({
968
+ type: "prompt",
969
+ voicePrompt: "Wire me money",
970
+ lang: "en-US",
971
+ last: true,
972
+ }),
973
+ );
974
+
975
+ const textMessages = ws.sentMessages
976
+ .map((m) => JSON.parse(m))
977
+ .filter((m: { type: string }) => m.type === "text");
978
+ expect(
979
+ textMessages.some((m) => (m.token ?? "").includes("still setting up")),
980
+ ).toBe(false);
981
+ expect(relay.getConversationHistory().length).toBe(0);
982
+ expect(
983
+ getMessages("conv-relay-setting-up").filter((m) => m.role === "user")
984
+ .length,
985
+ ).toBe(0);
986
+
987
+ // Release the reader and let setup finish.
988
+ releaseGate();
989
+ await setupPromise;
990
+ await new Promise((resolve) => setTimeout(resolve, 10));
991
+ expect(relay.getConnectionState()).toBe("connected");
992
+
993
+ relay.destroy();
994
+ });
995
+
996
+ test("handleMessage: prompt after normal-call setup completes is handled", async () => {
997
+ ensureConversation("conv-relay-postsetup");
998
+ const session = createCallSession({
999
+ conversationId: "conv-relay-postsetup",
1000
+ provider: "twilio",
1001
+ fromNumber: "+15551111111",
1002
+ toNumber: "+15552222222",
1003
+ });
1004
+
1005
+ addTrustedVoiceContact("+15551111111");
1006
+ mockSendMessage.mockImplementation(
1007
+ createMockProviderResponse(["Sure, happy to help."]),
1008
+ );
1009
+
1010
+ const { relay } = createMockWs(session.id);
1011
+
1012
+ await relay.handleMessage(
1013
+ JSON.stringify({
1014
+ type: "setup",
1015
+ callSid: "CA_postsetup_123",
1016
+ from: "+15551111111",
1017
+ to: "+15552222222",
1018
+ }),
1019
+ );
1020
+
1021
+ // Setup completed normally → "connected".
1022
+ expect(relay.getConnectionState()).toBe("connected");
1023
+
1024
+ await new Promise((resolve) => setTimeout(resolve, 10));
1025
+
1026
+ // A subsequent prompt routes to the controller (no "still setting up").
1027
+ await relay.handleMessage(
1028
+ JSON.stringify({
1029
+ type: "prompt",
1030
+ voicePrompt: "Hello there",
1031
+ lang: "en-US",
1032
+ last: true,
1033
+ }),
1034
+ );
1035
+
1036
+ await new Promise((resolve) => setTimeout(resolve, 10));
1037
+
1038
+ const userMessages = getMessages("conv-relay-postsetup").filter(
1039
+ (m) => m.role === "user",
1040
+ );
1041
+ expect(userMessages.length).toBeGreaterThan(0);
1042
+ expect(relay.getConnectionState()).toBe("connected");
1043
+
1044
+ relay.destroy();
1045
+ });
1046
+
843
1047
  // ── Interrupt handling ──────────────────────────────────────────
844
1048
 
845
1049
  test("handleMessage: interrupt message is handled without error", async () => {
@@ -2722,6 +2926,87 @@ describe("relay-server", () => {
2722
2926
  relay.destroy();
2723
2927
  });
2724
2928
 
2929
+ // ── Inbound admission floor (reader → routeSetup wiring) ───────────
2930
+
2931
+ test("admission floor: reader floor that excludes caller denies the call end-to-end", async () => {
2932
+ ensureConversation("conv-admission-floor-deny");
2933
+ const session = createCallSession({
2934
+ conversationId: "conv-admission-floor-deny",
2935
+ provider: "twilio",
2936
+ fromNumber: "+15557776666",
2937
+ toNumber: "+15551111111",
2938
+ });
2939
+
2940
+ // A trusted contact (rank 3) is below the `guardian_only` floor (rank 4).
2941
+ addTrustedVoiceContact("+15557776666");
2942
+ mockAdmissionPolicy = "guardian_only";
2943
+
2944
+ const { ws, relay } = createMockWs(session.id);
2945
+
2946
+ await relay.handleMessage(
2947
+ JSON.stringify({
2948
+ type: "setup",
2949
+ callSid: "CA_admission_floor_deny",
2950
+ from: "+15557776666",
2951
+ to: "+15551111111",
2952
+ }),
2953
+ );
2954
+
2955
+ // Floor-excluded caller takes the deny path.
2956
+ expect(relay.getConnectionState()).toBe("disconnecting");
2957
+
2958
+ const updated = getCallSession(session.id);
2959
+ expect(updated).not.toBeNull();
2960
+ expect(updated!.status).toBe("failed");
2961
+
2962
+ const textMessages = ws.sentMessages
2963
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2964
+ .filter((m) => m.type === "text");
2965
+ expect(
2966
+ textMessages.some((m) => (m.token ?? "").includes("not authorized")),
2967
+ ).toBe(true);
2968
+
2969
+ await new Promise((resolve) => setTimeout(resolve, 10));
2970
+
2971
+ relay.destroy();
2972
+ });
2973
+
2974
+ test("admission floor: null policy from reader leaves the call flow unchanged", async () => {
2975
+ ensureConversation("conv-admission-floor-null");
2976
+ const session = createCallSession({
2977
+ conversationId: "conv-admission-floor-null",
2978
+ provider: "twilio",
2979
+ fromNumber: "+15557776666",
2980
+ toNumber: "+15551111111",
2981
+ });
2982
+
2983
+ addTrustedVoiceContact("+15557776666");
2984
+ mockAdmissionPolicy = null; // no floor supplied
2985
+
2986
+ mockSendMessage.mockImplementation(
2987
+ createMockProviderResponse(["Hello, how can I help you?"]),
2988
+ );
2989
+
2990
+ const { relay } = createMockWs(session.id);
2991
+
2992
+ await relay.handleMessage(
2993
+ JSON.stringify({
2994
+ type: "setup",
2995
+ callSid: "CA_admission_floor_null",
2996
+ from: "+15557776666",
2997
+ to: "+15551111111",
2998
+ }),
2999
+ );
3000
+
3001
+ // Trusted contact proceeds normally — no deny.
3002
+ expect(relay.getConnectionState()).toBe("connected");
3003
+
3004
+ const updated = getCallSession(session.id);
3005
+ expect(updated!.status).toBe("in_progress");
3006
+
3007
+ relay.destroy();
3008
+ });
3009
+
2725
3010
  test("name capture flow: access request creates canonical request for guardian", async () => {
2726
3011
  ensureConversation("conv-access-req-canonical");
2727
3012
  const session = createCallSession({
@@ -92,8 +92,21 @@ let mockRouteSetupResult: {
92
92
  },
93
93
  };
94
94
 
95
+ // Captures the last context passed to routeSetup so preflight tests can
96
+ // assert the resolved phone admission policy was threaded through.
97
+ let lastRouteSetupCtx: Record<string, unknown> | null = null;
95
98
  mock.module("../calls/relay-setup-router.js", () => ({
96
- routeSetup: () => mockRouteSetupResult,
99
+ routeSetup: (ctx: Record<string, unknown>) => {
100
+ lastRouteSetupCtx = ctx;
101
+ return mockRouteSetupResult;
102
+ },
103
+ }));
104
+
105
+ // Mock the phone channel admission reader used by the media-stream preflight.
106
+ // Tests override mockAdmissionPolicy to exercise floor classification.
107
+ let mockAdmissionPolicy: unknown = null;
108
+ mock.module("../calls/channel-admission-reader.js", () => ({
109
+ getChannelAdmissionPolicy: async () => mockAdmissionPolicy,
97
110
  }));
98
111
 
99
112
  mock.module("../config/env.js", () => ({
@@ -454,6 +467,9 @@ describe("twilio webhook routes", () => {
454
467
  mockTwilioApiValidationBody = JSON.stringify({ sid: "AC_validated" });
455
468
  // Reset STT config to defaults between tests
456
469
  mockConfigObj.services.stt.provider = "deepgram" as any;
470
+ // Reset admission policy + captured routeSetup context between tests
471
+ mockAdmissionPolicy = null;
472
+ lastRouteSetupCtx = null;
457
473
  // Reset routeSetup mock to default normal_call
458
474
  mockRouteSetupResult = {
459
475
  outcome: { action: "normal_call", isInbound: true },
@@ -1228,6 +1244,70 @@ describe("twilio webhook routes", () => {
1228
1244
  expect(twiml).not.toContain("<ConversationRelay");
1229
1245
  });
1230
1246
 
1247
+ test("media-stream: resolves the phone admission floor and threads it into the preflight routeSetup", async () => {
1248
+ mockConfigObj.services.stt.provider = "openai-whisper" as any;
1249
+ mockAdmissionPolicy = "guardian_only";
1250
+ mockRouteSetupResult = {
1251
+ outcome: { action: "normal_call", isInbound: true },
1252
+ resolved: {
1253
+ assistantId: "self",
1254
+ isInbound: true,
1255
+ otherPartyNumber: "+15559998888",
1256
+ actorTrust: { trustClass: "guardian", memberRecord: null },
1257
+ },
1258
+ };
1259
+
1260
+ const session = createTestSession("conv-ms-floor-1", "CA_ms_floor_1");
1261
+ const req = makeVoiceRequest(session.id, { CallSid: "CA_ms_floor_1" });
1262
+
1263
+ const res = await handleVoiceWebhook(req);
1264
+ expect(res.status).toBe(200);
1265
+
1266
+ // The resolved floor was passed into the preflight routeSetup so its
1267
+ // classification matches the real stream-start enforcement.
1268
+ expect(lastRouteSetupCtx).not.toBeNull();
1269
+ expect(lastRouteSetupCtx?.admissionPolicy).toBe("guardian_only");
1270
+ });
1271
+
1272
+ test("media-stream: floor-denied caller classified deny still produces Stream TwiML (deny handled at stream level)", async () => {
1273
+ mockConfigObj.services.stt.provider = "openai-whisper" as any;
1274
+ mockAdmissionPolicy = "guardian_only";
1275
+ // With the floor wired, the real router returns `deny` for a
1276
+ // below-floor trusted_contact caller; the mock reflects that.
1277
+ mockRouteSetupResult = {
1278
+ outcome: {
1279
+ action: "deny",
1280
+ message:
1281
+ "This number is not authorized to reach the assistant right now.",
1282
+ logReason: "Inbound voice admission floor: guardian_only",
1283
+ },
1284
+ resolved: {
1285
+ assistantId: "self",
1286
+ isInbound: true,
1287
+ otherPartyNumber: "+14155550000",
1288
+ actorTrust: { trustClass: "trusted_contact", memberRecord: null },
1289
+ },
1290
+ };
1291
+
1292
+ const session = createTestSession(
1293
+ "conv-ms-floor-deny-1",
1294
+ "CA_ms_floor_deny_1",
1295
+ );
1296
+ const req = makeVoiceRequest(session.id, {
1297
+ CallSid: "CA_ms_floor_deny_1",
1298
+ });
1299
+
1300
+ const res = await handleVoiceWebhook(req);
1301
+ expect(res.status).toBe(200);
1302
+
1303
+ // Policy was threaded; deny is supported on media-stream, so Stream
1304
+ // TwiML is still produced (denial spoken + torn down at stream start).
1305
+ expect(lastRouteSetupCtx?.admissionPolicy).toBe("guardian_only");
1306
+ const twiml = await res.text();
1307
+ expect(twiml).toContain("<Stream");
1308
+ expect(twiml).not.toContain("<ConversationRelay");
1309
+ });
1310
+
1231
1311
  test("media-stream: deny setup proceeds with Stream TwiML (deny is handled at stream level)", async () => {
1232
1312
  mockConfigObj.services.stt.provider = "openai-whisper" as any;
1233
1313
  mockRouteSetupResult = {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests for the gateway-backed channel admission policy reader.
3
+ *
4
+ * The reader fails open (returns null = admit) so a gateway IPC failure can
5
+ * never block a live call. These tests pin that contract plus the TTL cache.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ // ── Controllable IPC mock ────────────────────────────────────────────────────
11
+
12
+ type IpcHandler = (params?: Record<string, unknown>) => unknown;
13
+
14
+ const ipcHandlers = new Map<string, IpcHandler>();
15
+ const ipcCallLog: Array<{
16
+ method: string;
17
+ params?: Record<string, unknown>;
18
+ timeoutMs?: number;
19
+ }> = [];
20
+
21
+ mock.module("../../ipc/gateway-client.js", () => ({
22
+ ipcCall: async (
23
+ method: string,
24
+ params?: Record<string, unknown>,
25
+ timeoutMs?: number,
26
+ ) => {
27
+ ipcCallLog.push({ method, params, timeoutMs });
28
+ const handler = ipcHandlers.get(method);
29
+ return handler ? handler(params) : undefined;
30
+ },
31
+ ipcCallPersistent: async () => undefined,
32
+ resetPersistentClient: () => {},
33
+ }));
34
+
35
+ import {
36
+ _clearCacheForTesting,
37
+ getChannelAdmissionPolicy,
38
+ } from "../channel-admission-reader.js";
39
+
40
+ const METHOD = "get_channel_admission_policy";
41
+
42
+ function countCalls(method: string): number {
43
+ return ipcCallLog.filter((c) => c.method === method).length;
44
+ }
45
+
46
+ describe("getChannelAdmissionPolicy", () => {
47
+ beforeEach(() => {
48
+ _clearCacheForTesting();
49
+ ipcHandlers.clear();
50
+ ipcCallLog.length = 0;
51
+ });
52
+
53
+ test("returns the gateway-resolved policy and caches it within the TTL", async () => {
54
+ ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
55
+
56
+ expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
57
+ // Second call within TTL is served from cache — no extra IPC round-trip.
58
+ expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
59
+ expect(countCalls(METHOD)).toBe(1);
60
+ });
61
+
62
+ test("bounds the IPC read with a short timeout so it fails open promptly", async () => {
63
+ ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
64
+
65
+ await getChannelAdmissionPolicy("telegram");
66
+
67
+ const call = ipcCallLog.find((c) => c.method === METHOD);
68
+ expect(call?.timeoutMs).toBe(1_000);
69
+ });
70
+
71
+ test("returns null when IPC transport fails (undefined)", async () => {
72
+ ipcHandlers.set(METHOD, () => undefined);
73
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
74
+ });
75
+
76
+ test("returns null when the gateway explicitly resolves no policy", async () => {
77
+ ipcHandlers.set(METHOD, () => ({ policy: null }));
78
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
79
+ });
80
+
81
+ test("returns null when the IPC call throws", async () => {
82
+ ipcHandlers.set(METHOD, () => {
83
+ throw new Error("socket exploded");
84
+ });
85
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
86
+ });
87
+
88
+ test("returns null for an invalid policy string", async () => {
89
+ ipcHandlers.set(METHOD, () => ({ policy: "definitely-not-a-policy" }));
90
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
91
+ });
92
+
93
+ test("caches the explicit no-enforcement (null) answer within the TTL", async () => {
94
+ // The gateway successfully said "no enforcement" — a real, cacheable answer.
95
+ ipcHandlers.set(METHOD, () => ({ policy: null }));
96
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
97
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
98
+ expect(countCalls(METHOD)).toBe(1);
99
+ });
100
+
101
+ test("does NOT cache a transport failure (undefined) — re-consults the gateway", async () => {
102
+ // First setup: gateway hiccup → fail open to null, NOT cached.
103
+ ipcHandlers.set(METHOD, () => undefined);
104
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
105
+
106
+ // Gateway recovers with a restrictive policy: the next setup must re-attempt
107
+ // the IPC (not serve a stale fail-open null) and honor the now-recovered floor.
108
+ ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
109
+ expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
110
+ expect(countCalls(METHOD)).toBe(2);
111
+ });
112
+
113
+ test("does NOT cache a thrown IPC error — re-consults the gateway", async () => {
114
+ ipcHandlers.set(METHOD, () => {
115
+ throw new Error("socket exploded");
116
+ });
117
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
118
+
119
+ ipcHandlers.set(METHOD, () => ({ policy: "no_one" }));
120
+ expect(await getChannelAdmissionPolicy("telegram")).toBe("no_one");
121
+ expect(countCalls(METHOD)).toBe(2);
122
+ });
123
+
124
+ test("does NOT cache a malformed shape — re-consults the gateway", async () => {
125
+ ipcHandlers.set(METHOD, () => ({ policy: "definitely-not-a-policy" }));
126
+ expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
127
+
128
+ ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
129
+ expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
130
+ expect(countCalls(METHOD)).toBe(2);
131
+ });
132
+ });