|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#12537, ruling of 2026-08-29 — option B, part 2 (the rider)] What a |
| 5 | + * SWALLOWED execution-context resolution reads as INSIDE the packages door's |
| 6 | + * permission decision. |
| 7 | + * |
| 8 | + * ## The question this file answers, and the question it does NOT |
| 9 | + * |
| 10 | + * The thread already carried a wire-level reading: a resolver that throws |
| 11 | + * SYNCHRONOUSLY reaches `sendThrownError`, and one that REJECTS is swallowed |
| 12 | + * and the caller sees the anonymous-deny floor. That is an answer about which |
| 13 | + * status code comes out. The rider asks something else, and the two are not |
| 14 | + * the same question: what does an `undefined` execution context MEAN to the |
| 15 | + * gate — is it |
| 16 | + * |
| 17 | + * (1) a SUBJECT that holds nothing (evaluated, denies), |
| 18 | + * (2) an evaluation that is SKIPPED, or |
| 19 | + * (3) a fall-through to a DEFAULT / SYSTEM subject? |
| 20 | + * |
| 21 | + * ⛔ Those three can produce byte-identical responses. (2) and (3) are |
| 22 | + * fail-open postures and (1) is not, so "the status code was the same" is not |
| 23 | + * evidence that the internal reading was the same. Every case below therefore |
| 24 | + * drives a DISCRIMINATOR — an input on which the three readings disagree — |
| 25 | + * and every "did not happen" reading is stated next to a same-shaped POSITIVE |
| 26 | + * CONTROL, because a zero from an instrument that was never shown to produce a |
| 27 | + * one is a false negative, not a measurement. |
| 28 | + * |
| 29 | + * ## Measured answer: (1). The fault is folded onto the ANONYMOUS subject. |
| 30 | + * |
| 31 | + * `refusePackageRequest` (`package-routes.ts`) reads the resolved context |
| 32 | + * through optional chaining only — `ctx?.userId`, `ctx?.isSystem`, |
| 33 | + * `ctx?.systemPermissions` — so `undefined` is not a branch, it is a subject |
| 34 | + * whose every field is absent: |
| 35 | + * |
| 36 | + * - `shouldDenyAnonymous({ userId: undefined, isSystem: undefined, ... })` |
| 37 | + * returns `true` ⇒ 401 `UNAUTHENTICATED`. The gate is REACHED and it |
| 38 | + * DENIES; it is not skipped (section 3) and it does not reach a system |
| 39 | + * subject (section 2). |
| 40 | + * - When the anonymous floor is not the deciding clause, the capability |
| 41 | + * clause evaluates the same `undefined` as a subject holding the EMPTY |
| 42 | + * capability set ⇒ 403 `FORBIDDEN` (section 4). `ctx?.isSystem` is |
| 43 | + * `undefined`, so the `isSystem` escape hatch is not taken either. |
| 44 | + * |
| 45 | + * ⇒ The swallow fails CLOSED at this door. It is NOT a permission-adjacent |
| 46 | + * fail-open. What it costs is DIAGNOSABILITY, and section 5 measures that |
| 47 | + * cost exactly: a faulting resolver, an absent resolver, and a genuinely |
| 48 | + * anonymous caller are ONE answer, indistinguishable on the wire. |
| 49 | + * |
| 50 | + * ## ⭐ The swallow at the wrapper is the SECOND net, not the first |
| 51 | + * |
| 52 | + * Section 6 measures the production supplier rather than assuming it. |
| 53 | + * `RestServer.computeExecCtx` wraps its whole body in `try { ... } catch { |
| 54 | + * return undefined; }`, so the production `resolveExecCtx` RESOLVES WITH |
| 55 | + * `undefined` on a fault instead of rejecting. The `.catch(() => undefined)` |
| 56 | + * on the packages-door wrapper `resolvePackageRouteExecutionContext` — and |
| 57 | + * the second one at `package-routes.ts` — therefore have nothing to catch on |
| 58 | + * that path. The fault-to-anonymous conversion happens INSIDE |
| 59 | + * `computeExecCtx`; removing either `.catch` would not by itself surface a |
| 60 | + * production fault. ⛔ That is measured here, not repaired: un-swallowing is |
| 61 | + * explicitly NOT ruled on this card. |
| 62 | + * |
| 63 | + * ## ⛔ What this file must not become |
| 64 | + * |
| 65 | + * Nothing here asserts a status code as an END in itself; every status is |
| 66 | + * read as the OBSERVABLE of a decision, and the decision is what is pinned. |
| 67 | + * A future edit that makes the gate read `undefined` as a system or default |
| 68 | + * subject reds section 2 and section 4 — which is the whole point of writing |
| 69 | + * the reading down rather than the response. |
| 70 | + */ |
| 71 | + |
| 72 | +import { describe, it, expect, vi } from 'vitest'; |
| 73 | +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; |
| 74 | +import type { RouteHandler } from '@objectstack/spec/contracts'; |
| 75 | +import { registerPackageRoutes } from './package-routes.js'; |
| 76 | +import { RestServer } from './rest-server.js'; |
| 77 | + |
| 78 | +const PKGS = '/api/v1/packages'; |
| 79 | + |
| 80 | +interface Captured { |
| 81 | + status: number; |
| 82 | + body: any; |
| 83 | +} |
| 84 | + |
| 85 | +/** A caller holding every capability these routes gate on. */ |
| 86 | +const CLEARS_THE_GATE = async () => ({ |
| 87 | + userId: 'u_pkg', |
| 88 | + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], |
| 89 | +}); |
| 90 | + |
| 91 | +function mount(options: Record<string, unknown> = {}): Map<string, RouteHandler> { |
| 92 | + const routes = new Map<string, RouteHandler>(); |
| 93 | + const server = { |
| 94 | + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, |
| 95 | + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, |
| 96 | + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, |
| 97 | + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, |
| 98 | + patch: () => {}, |
| 99 | + use: () => {}, |
| 100 | + listen: async () => {}, |
| 101 | + close: async () => {}, |
| 102 | + } as any; |
| 103 | + registerPackageRoutes(server, () => ({ list: async () => [] }) as any, '/api/v1', { |
| 104 | + resolveExecutionContext: CLEARS_THE_GATE, |
| 105 | + ...options, |
| 106 | + } as any); |
| 107 | + return routes; |
| 108 | +} |
| 109 | + |
| 110 | +async function drive( |
| 111 | + routes: Map<string, RouteHandler>, |
| 112 | + method: string, |
| 113 | + path: string, |
| 114 | + req: Record<string, any> = {}, |
| 115 | +): Promise<Captured> { |
| 116 | + const handler = routes.get(`${method}:${path}`); |
| 117 | + if (!handler) throw new Error(`no handler for ${method} ${path}`); |
| 118 | + const captured: Captured = { status: 0, body: undefined }; |
| 119 | + const res: any = { |
| 120 | + json(data: any) { captured.body = data; }, |
| 121 | + send() {}, |
| 122 | + status(code: number) { captured.status = code; return res; }, |
| 123 | + header() { return res; }, |
| 124 | + }; |
| 125 | + await handler( |
| 126 | + { params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any, |
| 127 | + res, |
| 128 | + ); |
| 129 | + return captured; |
| 130 | +} |
| 131 | + |
| 132 | +/** `GET /packages` under one resolver wiring. `undefined` ⇒ no resolver wired. */ |
| 133 | +const listUnder = (resolveExecutionContext: unknown, req: Record<string, any> = {}) => |
| 134 | + drive( |
| 135 | + mount(resolveExecutionContext === undefined ? { resolveExecutionContext: undefined } : { resolveExecutionContext }), |
| 136 | + 'GET', |
| 137 | + PKGS, |
| 138 | + req, |
| 139 | + ); |
| 140 | + |
| 141 | +/** The three ways this door can end up holding `undefined`. */ |
| 142 | +const REJECTS = async () => { throw new Error('resolver fault'); }; |
| 143 | +const RESOLVES_UNDEFINED = async () => undefined; |
| 144 | + |
| 145 | +// --------------------------------------------------------------------------- |
| 146 | +// 1. POSITIVE CONTROLS — this harness can observe an ALLOW, and can observe |
| 147 | +// each refusal clause separately. Every zero below is read against these. |
| 148 | +// --------------------------------------------------------------------------- |
| 149 | + |
| 150 | +describe('[#12537] controls — the instrument produces a one before any zero is read', () => { |
| 151 | + it('CONTROL (allow is observable): a fully capable context is served 200', async () => { |
| 152 | + const captured = await listUnder(CLEARS_THE_GATE); |
| 153 | + expect(captured.status).toBe(200); |
| 154 | + expect(captured.body?.success).toBe(true); |
| 155 | + }); |
| 156 | + |
| 157 | + it('CONTROL (the anonymous clause is observable): a named subject is NOT 401', async () => { |
| 158 | + const captured = await listUnder(async () => ({ userId: 'u_named', systemPermissions: [] })); |
| 159 | + expect(captured.status).not.toBe(ANONYMOUS_DENY_STATUS); |
| 160 | + expect(captured.status).toBe(403); |
| 161 | + expect(captured.body?.error?.code).toBe('FORBIDDEN'); |
| 162 | + }); |
| 163 | + |
| 164 | + it('CONTROL (the resolver really runs): the door calls it exactly once per request', async () => { |
| 165 | + const resolver = vi.fn(REJECTS); |
| 166 | + await listUnder(resolver); |
| 167 | + expect(resolver.mock.calls.length).toBe(1); |
| 168 | + }); |
| 169 | + |
| 170 | + it('CONTROL (the rejection really rejects): the injected resolver is a rejecting promise', async () => { |
| 171 | + await expect(REJECTS()).rejects.toThrow('resolver fault'); |
| 172 | + }); |
| 173 | +}); |
| 174 | + |
| 175 | +// --------------------------------------------------------------------------- |
| 176 | +// 2. READING (3) FALSIFIED — `undefined` is NOT a default / system subject. |
| 177 | +// --------------------------------------------------------------------------- |
| 178 | + |
| 179 | +describe('[#12537] a swallowed resolution does not fall through to a system subject', () => { |
| 180 | + it('a rejecting resolver is REFUSED, not served', async () => { |
| 181 | + const captured = await listUnder(REJECTS); |
| 182 | + expect(captured.status).toBe(ANONYMOUS_DENY_STATUS); |
| 183 | + expect(captured.body?.success).toBe(false); |
| 184 | + expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); |
| 185 | + }); |
| 186 | + |
| 187 | + it('CONTROL: a real system subject IS served — so "refused" above is a decision, not an artefact', async () => { |
| 188 | + const captured = await listUnder(async () => ({ isSystem: true })); |
| 189 | + expect(captured.status).toBe(200); |
| 190 | + expect(captured.body?.success).toBe(true); |
| 191 | + }); |
| 192 | +}); |
| 193 | + |
| 194 | +// --------------------------------------------------------------------------- |
| 195 | +// 3. READING (2) FALSIFIED — the gate is not SKIPPED for `undefined`. |
| 196 | +// --------------------------------------------------------------------------- |
| 197 | + |
| 198 | +describe('[#12537] a swallowed resolution does not bypass the gate', () => { |
| 199 | + it('the service is never reached when the resolver rejects', async () => { |
| 200 | + const list = vi.fn(async () => []); |
| 201 | + const routes = new Map<string, RouteHandler>(); |
| 202 | + const server = { |
| 203 | + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, |
| 204 | + post: () => {}, put: () => {}, delete: () => {}, patch: () => {}, |
| 205 | + use: () => {}, listen: async () => {}, close: async () => {}, |
| 206 | + } as any; |
| 207 | + registerPackageRoutes(server, () => ({ list }) as any, '/api/v1', { |
| 208 | + resolveExecutionContext: REJECTS, |
| 209 | + } as any); |
| 210 | + const captured = await drive(routes, 'GET', PKGS); |
| 211 | + expect(captured.status).toBe(ANONYMOUS_DENY_STATUS); |
| 212 | + // ⚠️ ZERO. Its control is the next assertion, on the SAME `list` spy shape. |
| 213 | + expect(list.mock.calls.length).toBe(0); |
| 214 | + }); |
| 215 | + |
| 216 | + it('CONTROL: the same spy DOES record a call when the gate is cleared', async () => { |
| 217 | + const list = vi.fn(async () => []); |
| 218 | + const routes = new Map<string, RouteHandler>(); |
| 219 | + const server = { |
| 220 | + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, |
| 221 | + post: () => {}, put: () => {}, delete: () => {}, patch: () => {}, |
| 222 | + use: () => {}, listen: async () => {}, close: async () => {}, |
| 223 | + } as any; |
| 224 | + registerPackageRoutes(server, () => ({ list }) as any, '/api/v1', { |
| 225 | + resolveExecutionContext: CLEARS_THE_GATE, |
| 226 | + } as any); |
| 227 | + const captured = await drive(routes, 'GET', PKGS); |
| 228 | + expect(captured.status).toBe(200); |
| 229 | + expect(list.mock.calls.length).toBe(1); |
| 230 | + }); |
| 231 | +}); |
| 232 | + |
| 233 | +// --------------------------------------------------------------------------- |
| 234 | +// 4. READING (1) CONFIRMED — the CAPABILITY clause evaluates `undefined` as a |
| 235 | +// subject holding the EMPTY set. |
| 236 | +// |
| 237 | +// ⚠️ DISCLOSURE, so no reader mistakes this for a wire path: the packages |
| 238 | +// registrar mounts exactly four routes (POST publish, GET list, GET by id, |
| 239 | +// DELETE by id) and NO `OPTIONS` route, so a real preflight never reaches |
| 240 | +// these handlers. `method: 'OPTIONS'` is used here as the one INPUT that |
| 241 | +// makes the shared `shouldDenyAnonymous` yield without authenticating — |
| 242 | +// i.e. as an instrument for isolating the capability clause from the |
| 243 | +// anonymous clause, which otherwise short-circuits ahead of it. That |
| 244 | +// isolation is the only way to tell "evaluated and holds nothing" apart |
| 245 | +// from "never evaluated": on a plain GET both readings answer 401. |
| 246 | +// --------------------------------------------------------------------------- |
| 247 | + |
| 248 | +describe('[#12537] the capability clause reads `undefined` as "holds nothing"', () => { |
| 249 | + it('past the anonymous clause, a swallowed resolution is 403 FORBIDDEN', async () => { |
| 250 | + const captured = await listUnder(REJECTS, { method: 'OPTIONS' }); |
| 251 | + expect(captured.status).toBe(403); |
| 252 | + expect(captured.body?.error?.code).toBe('FORBIDDEN'); |
| 253 | + expect(captured.body?.error?.message).toContain('studio.access'); |
| 254 | + }); |
| 255 | + |
| 256 | + it('CONTROL: past the same clause, a CAPABLE context is served 200', async () => { |
| 257 | + const captured = await listUnder(CLEARS_THE_GATE, { method: 'OPTIONS' }); |
| 258 | + expect(captured.status).toBe(200); |
| 259 | + expect(captured.body?.success).toBe(true); |
| 260 | + }); |
| 261 | + |
| 262 | + it('CONTROL: past the same clause, an explicit EMPTY capability set is the same 403', async () => { |
| 263 | + const captured = await listUnder( |
| 264 | + async () => ({ userId: 'u_named', systemPermissions: [] }), |
| 265 | + { method: 'OPTIONS' }, |
| 266 | + ); |
| 267 | + expect(captured.status).toBe(403); |
| 268 | + expect(captured.body?.error?.code).toBe('FORBIDDEN'); |
| 269 | + }); |
| 270 | +}); |
| 271 | + |
| 272 | +// --------------------------------------------------------------------------- |
| 273 | +// 5. THE COST — the three origins of `undefined` are ONE answer. |
| 274 | +// This is the defect the card actually describes: not an over-permission, |
| 275 | +// a refusal whose stated reason is wrong for two of the three origins. |
| 276 | +// --------------------------------------------------------------------------- |
| 277 | + |
| 278 | +describe('[#12537] a resolver FAULT is indistinguishable from anonymity and from no resolver', () => { |
| 279 | + it('rejecting resolver, resolver returning undefined, and no resolver agree byte for byte', async () => { |
| 280 | + const [faulted, anonymous, unwired] = await Promise.all([ |
| 281 | + listUnder(REJECTS), |
| 282 | + listUnder(RESOLVES_UNDEFINED), |
| 283 | + listUnder(undefined), |
| 284 | + ]); |
| 285 | + expect(faulted.status).toBe(ANONYMOUS_DENY_STATUS); |
| 286 | + expect(JSON.stringify(faulted)).toBe(JSON.stringify(anonymous)); |
| 287 | + expect(JSON.stringify(faulted)).toBe(JSON.stringify(unwired)); |
| 288 | + }); |
| 289 | + |
| 290 | + it('CONTROL: the same comparison SEPARATES two answers that differ', async () => { |
| 291 | + const [faulted, capable] = await Promise.all([listUnder(REJECTS), listUnder(CLEARS_THE_GATE)]); |
| 292 | + expect(JSON.stringify(faulted)).not.toBe(JSON.stringify(capable)); |
| 293 | + }); |
| 294 | + |
| 295 | + it('every state-changing route reads the fault the same way', async () => { |
| 296 | + const routes = mount({ resolveExecutionContext: REJECTS }); |
| 297 | + const del = await drive(routes, 'DELETE', `${PKGS}/:id`, { params: { id: 'com.acme.crm' } }); |
| 298 | + const pub = await drive(routes, 'POST', `${PKGS}/publish`, { |
| 299 | + body: { manifest: { id: 'com.acme.crm', version: '1.0.0' } }, |
| 300 | + }); |
| 301 | + expect(del.status).toBe(ANONYMOUS_DENY_STATUS); |
| 302 | + expect(pub.status).toBe(ANONYMOUS_DENY_STATUS); |
| 303 | + }); |
| 304 | +}); |
| 305 | + |
| 306 | +// --------------------------------------------------------------------------- |
| 307 | +// 6. THE PRODUCTION SUPPLIER — the wrapper's `.catch` is the SECOND net. |
| 308 | +// --------------------------------------------------------------------------- |
| 309 | + |
| 310 | +describe('[#12537] the production resolver resolves with `undefined`; it does not reject', () => { |
| 311 | + const restWith = (authServiceProvider: (environmentId?: string) => Promise<any>) => |
| 312 | + new RestServer( |
| 313 | + { get: () => {}, post: () => {}, put: () => {}, delete: () => {}, patch: () => {}, use: () => {} } as any, |
| 314 | + {} as any, |
| 315 | + {} as any, |
| 316 | + undefined, |
| 317 | + undefined, |
| 318 | + undefined, |
| 319 | + authServiceProvider, |
| 320 | + ); |
| 321 | + |
| 322 | + /** Fulfilled-or-rejected, as an observable value rather than an assertion. */ |
| 323 | + const settle = async (p: Promise<unknown>): Promise<'fulfilled' | 'rejected'> => |
| 324 | + p.then(() => 'fulfilled' as const, () => 'rejected' as const); |
| 325 | + |
| 326 | + it('CONTROL: the witness distinguishes a rejected promise from a fulfilled one', async () => { |
| 327 | + expect(await settle(Promise.reject(new Error('control')))).toBe('rejected'); |
| 328 | + expect(await settle(Promise.resolve(1))).toBe('fulfilled'); |
| 329 | + }); |
| 330 | + |
| 331 | + it('a faulting auth-service provider yields a FULFILLED `undefined` from the private resolver', async () => { |
| 332 | + const rest = restWith(() => { throw new Error('auth service exploded'); }); |
| 333 | + const req = { params: {}, headers: {}, method: 'GET', path: PKGS }; |
| 334 | + // ⭐ The PRIVATE resolver, read BEFORE the wrapper's `.catch` can act — |
| 335 | + // this is what shows the wrapper is not the thing converting the fault. |
| 336 | + const inner = (rest as any).resolveExecCtx(undefined, req); |
| 337 | + expect(await settle(inner)).toBe('fulfilled'); |
| 338 | + expect(await inner).toBeUndefined(); |
| 339 | + }); |
| 340 | + |
| 341 | + it('CONTROL: when the inner resolve DOES reject, the wrapper is what swallows it', async () => { |
| 342 | + const rest = restWith(async () => undefined); |
| 343 | + (rest as any).computeExecCtx = async () => { throw new Error('injected inner rejection'); }; |
| 344 | + const req = { params: {}, headers: {}, method: 'GET', path: PKGS }; |
| 345 | + // Same shape as the case above, with the one difference under test — so the |
| 346 | + // "fulfilled" reading there is a property of `computeExecCtx`, not of the |
| 347 | + // instrument. |
| 348 | + expect(await settle((rest as any).resolveExecCtx(undefined, req))).toBe('rejected'); |
| 349 | + expect(await settle(rest.resolvePackageRouteExecutionContext(req))).toBe('fulfilled'); |
| 350 | + expect(await rest.resolvePackageRouteExecutionContext({ ...req })).toBeUndefined(); |
| 351 | + }); |
| 352 | +}); |
| 353 | + |
| 354 | +// --------------------------------------------------------------------------- |
| 355 | +// 7. ⚠️ A CORRECTION to the reading this card's thread carried. |
| 356 | +// |
| 357 | +// The thread records "synchronous throw ⇒ 403 PERMISSION_DENIED" beside |
| 358 | +// "asynchronous rejection ⇒ 401" as if both were facts ABOUT THIS DOOR. |
| 359 | +// Only the second one is. A synchronous throw does not produce an |
| 360 | +// `undefined` context at all — it escapes the wrapper (which is NOT |
| 361 | +// `async`, so there is no promise for either `.catch` to attach to), |
| 362 | +// lands in the route's own `try`, and is answered by `sendThrownError` → |
| 363 | +// `resolveThrownHttpError`, which reads the STATUS OFF THE THROWN ERROR. |
| 364 | +// So the 403 is a property of the error that was injected, not a decision |
| 365 | +// this gate made: the same seam, thrown a plain `Error`, answers 500. |
| 366 | +// |
| 367 | +// ⇒ The two limbs are not two readings of one context. One is a refusal |
| 368 | +// DECIDED by the gate; the other is a status FORWARDED from a producer. |
| 369 | +// Nothing here re-opens the seam census: a production wrapper still cannot |
| 370 | +// throw synchronously (`req?.params?.environmentId` is optional-chained and |
| 371 | +// every downstream call resolves), so this limb remains the declared |
| 372 | +// test-only injection point. |
| 373 | +// --------------------------------------------------------------------------- |
| 374 | + |
| 375 | +describe('[#12537] a SYNC throw is forwarded from the producer, not decided by the gate', () => { |
| 376 | + const throwsSync = (error: unknown) => () => { throw error; }; |
| 377 | + |
| 378 | + it('a coded producer error keeps ITS status — the thread\'s 403 is this, not a gate decision', async () => { |
| 379 | + const captured = await listUnder( |
| 380 | + throwsSync(Object.assign(new Error('nope'), { code: 'PERMISSION_DENIED', status: 403 })), |
| 381 | + ); |
| 382 | + expect(captured.status).toBe(403); |
| 383 | + expect(captured.body?.error?.code).toBe('PERMISSION_DENIED'); |
| 384 | + }); |
| 385 | + |
| 386 | + it('the SAME seam, thrown a plain Error, answers 500 — so the status tracks the error', async () => { |
| 387 | + const captured = await listUnder(throwsSync(new Error('nope'))); |
| 388 | + expect(captured.status).toBe(500); |
| 389 | + expect(captured.body?.error?.code).not.toBe('PERMISSION_DENIED'); |
| 390 | + }); |
| 391 | + |
| 392 | + it('and neither of those is the swallowed case: a REJECTION is still the 401 floor', async () => { |
| 393 | + const captured = await listUnder( |
| 394 | + async () => { throw Object.assign(new Error('nope'), { code: 'PERMISSION_DENIED', status: 403 }); }, |
| 395 | + ); |
| 396 | + expect(captured.status).toBe(ANONYMOUS_DENY_STATUS); |
| 397 | + expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); |
| 398 | + }); |
| 399 | +}); |
0 commit comments