|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #7911 — the anonymous-deny gate is consulted BEFORE `/security`'s |
| 5 | + * capability answer, not after it. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `handleSecurityRequest` resolved the `security` service and returned a |
| 10 | + * capability answer — 503 "Security service not available" — for an empty or |
| 11 | + * non-duck-typing slot BEFORE it reached the `!ec || shouldDenyAnonymous(...)` |
| 12 | + * gate ~20 lines below. So on any deployment where the `security` slot is |
| 13 | + * empty or stubbed, an unauthenticated caller to |
| 14 | + * `/api/v1/security/suggested-bindings` got a 503 capability disclosure |
| 15 | + * instead of the 401 refusal this admin surface's own comment calls |
| 16 | + * UNCONDITIONAL (#2567, #3963). `/security` stands on the same anonymous-deny |
| 17 | + * floor as `/data`, `/meta`, `/actions` and `/automation` (ADR-0056 D2 → |
| 18 | + * #3963) — the sibling inversion on `/ai/**` was #7653, fixed in PR #7910; |
| 19 | + * this was the last of the six dispatcher domains still ordered the wrong way. |
| 20 | + * |
| 21 | + * ## What must NOT change, and why the authenticated half is pinned just as |
| 22 | + * hard |
| 23 | + * |
| 24 | + * A fix that 401s every caller unconditionally would satisfy Group A and |
| 25 | + * still be a regression: an AUTHENTICATED caller against an empty/stubbed |
| 26 | + * slot must still see the 503 "Security service not available" answer, |
| 27 | + * unchanged. That negative pin is what proves this is a hoist (WHEN the gate |
| 28 | + * decides) and not a deletion (WHAT it decides) — see `domains/security.ts`'s |
| 29 | + * `[#4127 batch 3]` comment on the `!ec` arm, preserved verbatim across the |
| 30 | + * move. |
| 31 | + * |
| 32 | + * No route-level `auth: false` opt-out exists on this domain (unlike `/ai`), |
| 33 | + * so there is exactly one consult site and no per-route loop to re-enter. |
| 34 | + */ |
| 35 | + |
| 36 | +import { describe, it, expect } from 'vitest'; |
| 37 | +import { |
| 38 | + ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, |
| 39 | +} from '@objectstack/core'; |
| 40 | + |
| 41 | +import { handleSecurityRequest } from './security.js'; |
| 42 | +import { apiErrorResponse } from '../error-envelope.js'; |
| 43 | +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; |
| 44 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 45 | + |
| 46 | +// ── contexts ──────────────────────────────────────────────────────────────── |
| 47 | +// Two anonymous shapes, both of which occur in the wild: `resolveExecutionContext` |
| 48 | +// leaves `executionContext` UNDEFINED when identity resolution throws, and |
| 49 | +// writes a `userId`-less record for a resolved-but-sessionless caller. |
| 50 | +const anonUnresolved = () => ({ request: { headers: {} } }) as unknown as HttpProtocolContext; |
| 51 | +const anonResolved = () => ({ |
| 52 | + request: { headers: {} }, |
| 53 | + executionContext: { isSystem: false, positions: [], permissions: [], systemPermissions: [] }, |
| 54 | +}) as unknown as HttpProtocolContext; |
| 55 | +const authed = () => ({ |
| 56 | + request: { headers: {} }, |
| 57 | + executionContext: { userId: 'usr_1', isSystem: false, positions: [], permissions: [], systemPermissions: [] }, |
| 58 | +}) as unknown as HttpProtocolContext; |
| 59 | +const system = () => ({ |
| 60 | + request: { headers: {} }, |
| 61 | + executionContext: { isSystem: true }, |
| 62 | +}) as unknown as HttpProtocolContext; |
| 63 | + |
| 64 | +// ── deps ──────────────────────────────────────────────────────────────────── |
| 65 | + |
| 66 | +/** |
| 67 | + * `error` is the REAL envelope builder the dispatcher wires in |
| 68 | + * (`http-dispatcher.ts` → `apiErrorResponse`), not a stub that drops the third |
| 69 | + * argument. Without it `details.code` would never be promoted into |
| 70 | + * `error.code` and every `code` assertion below would be vacuous — the exact |
| 71 | + * way an ADR-0112 envelope test can pass while asserting nothing. |
| 72 | + */ |
| 73 | +function makeDeps(opts: { |
| 74 | + /** `undefined` → the slot is empty; a truthy value with no duck-typed |
| 75 | + * methods reproduces the "stubbed occupant" arm of the same `if`. */ |
| 76 | + securityService?: any; |
| 77 | +} = {}): DomainHandlerDeps { |
| 78 | + return { |
| 79 | + resolveService: (async (_ctx: HttpProtocolContext, name: string) => |
| 80 | + (name === 'security' ? opts.securityService : undefined)) as any, |
| 81 | + success: (data: any) => ({ status: 200, body: { success: true, data } }), |
| 82 | + error: (message: string, httpStatus = 500, details?: any) => |
| 83 | + apiErrorResponse({ message, httpStatus, details }), |
| 84 | + errorFromThrown: (e: any, fallbackStatus = 500) => |
| 85 | + apiErrorResponse({ message: e?.message ?? 'Unexpected error', httpStatus: e?.status ?? e?.statusCode ?? fallbackStatus }), |
| 86 | + } as unknown as DomainHandlerDeps; |
| 87 | +} |
| 88 | + |
| 89 | +function dispatch(deps: DomainHandlerDeps, context: HttpProtocolContext, path: string, method = 'GET') { |
| 90 | + return handleSecurityRequest(deps, path, method, {}, {}, context); |
| 91 | +} |
| 92 | + |
| 93 | +/** Assert the ADR-0112 refusal envelope: status AND code, never one alone. */ |
| 94 | +function expectAnonymousDenied(result: any) { |
| 95 | + expect(result.handled).toBe(true); |
| 96 | + expect(result.response.status).toBe(ANONYMOUS_DENY_STATUS); |
| 97 | + expect(result.response.status).toBe(401); |
| 98 | + expect(result.response.body.success).toBe(false); |
| 99 | + expect(result.response.body.error.code).toBe(ANONYMOUS_DENY_CODE); |
| 100 | + expect(result.response.body.error.code).toBe('UNAUTHENTICATED'); |
| 101 | + expect(result.response.body.error.httpStatus).toBe(401); |
| 102 | + expect(result.response.body.error.message).toBe(ANONYMOUS_DENY_MESSAGE); |
| 103 | +} |
| 104 | + |
| 105 | +// ── Group A: the defect ───────────────────────────────────────────────────── |
| 106 | + |
| 107 | +describe('#7911 A — an empty/stubbed security slot still denies anonymous callers first', () => { |
| 108 | + it('GET /security/suggested-bindings, empty slot → 401, not the 503 capability answer', async () => { |
| 109 | + const result: any = await dispatch(makeDeps(), anonUnresolved(), '/suggested-bindings'); |
| 110 | + expectAnonymousDenied(result); |
| 111 | + expect(result.response.status).not.toBe(503); |
| 112 | + expect(JSON.stringify(result.response.body)).not.toContain('Security service not available'); |
| 113 | + }); |
| 114 | + |
| 115 | + it('GET /security/suggested-bindings, stubbed occupant (no duck-typed methods) → 401', async () => { |
| 116 | + // A truthy occupant with none of the contract's methods takes the same |
| 117 | + // `!service || typeof … !== 'function'` exit an empty slot takes. |
| 118 | + const result: any = await dispatch(makeDeps({ securityService: {} }), anonUnresolved(), '/suggested-bindings'); |
| 119 | + expectAnonymousDenied(result); |
| 120 | + }); |
| 121 | + |
| 122 | + it('denies the resolved-but-sessionless anonymous shape identically', async () => { |
| 123 | + expectAnonymousDenied(await dispatch(makeDeps(), anonResolved(), '/suggested-bindings')); |
| 124 | + }); |
| 125 | + |
| 126 | + it('covers the write routes too, not just the list', async () => { |
| 127 | + const cases: Array<[string, string]> = [ |
| 128 | + ['/suggested-bindings/sug_1/confirm', 'POST'], |
| 129 | + ['/suggested-bindings/sug_1/dismiss', 'POST'], |
| 130 | + ]; |
| 131 | + for (const [path, method] of cases) { |
| 132 | + expectAnonymousDenied(await dispatch(makeDeps(), anonUnresolved(), path, method)); |
| 133 | + } |
| 134 | + }); |
| 135 | +}); |
| 136 | + |
| 137 | +// ── Group B: the honest degradation — LOAD-BEARING, must stay green ───────── |
| 138 | + |
| 139 | +describe('#7911 B — the 503 capability answer is untouched for an authenticated caller', () => { |
| 140 | + it('still 503s an empty slot for an authenticated caller', async () => { |
| 141 | + const result: any = await dispatch(makeDeps(), authed(), '/suggested-bindings'); |
| 142 | + expect(result.handled).toBe(true); |
| 143 | + expect(result.response.status).toBe(503); |
| 144 | + expect(result.response.body.error.message).toBe('Security service not available'); |
| 145 | + }); |
| 146 | + |
| 147 | + it('still 503s a stubbed (non-duck-typing) occupant for an authenticated caller', async () => { |
| 148 | + const result: any = await dispatch(makeDeps({ securityService: {} }), authed(), '/suggested-bindings'); |
| 149 | + expect(result.response.status).toBe(503); |
| 150 | + expect(result.response.body.error.message).toBe('Security service not available'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('lets an internal SYSTEM context through to the same 503 degradation', async () => { |
| 154 | + // `isSystem` is never settable from the wire; a host dispatching |
| 155 | + // internally must not be caught by a caller-facing gate. |
| 156 | + const result: any = await dispatch(makeDeps(), system(), '/suggested-bindings'); |
| 157 | + expect(result.response.status).toBe(503); |
| 158 | + expect(result.response.body.error.message).toBe('Security service not available'); |
| 159 | + }); |
| 160 | +}); |
| 161 | + |
| 162 | +// ── Group C: the control — hoisted, not made blanket or left inert ────────── |
| 163 | + |
| 164 | +describe('#7911 C — a serveable service still works for an authenticated caller and still denies anonymous', () => { |
| 165 | + const suggestions = [{ id: 'sug_1', status: 'pending' }]; |
| 166 | + const served = { |
| 167 | + listAudienceBindingSuggestions: async () => suggestions, |
| 168 | + confirmAudienceBindingSuggestion: async (_ec: any, id: string) => ({ id, status: 'confirmed' }), |
| 169 | + dismissAudienceBindingSuggestion: async (_ec: any, id: string) => ({ id, status: 'dismissed' }), |
| 170 | + }; |
| 171 | + |
| 172 | + it('still denies anonymous even with a fully serveable service', async () => { |
| 173 | + const deps = makeDeps({ securityService: served }); |
| 174 | + expectAnonymousDenied(await dispatch(deps, anonUnresolved(), '/suggested-bindings')); |
| 175 | + }); |
| 176 | + |
| 177 | + it('serves an authenticated caller the list', async () => { |
| 178 | + const deps = makeDeps({ securityService: served }); |
| 179 | + const result: any = await dispatch(deps, authed(), '/suggested-bindings'); |
| 180 | + expect(result.handled).toBe(true); |
| 181 | + expect(result.response.status).toBe(200); |
| 182 | + expect(result.response.body).toEqual({ success: true, data: suggestions }); |
| 183 | + }); |
| 184 | + |
| 185 | + it('serves an authenticated confirm/dismiss', async () => { |
| 186 | + const deps = makeDeps({ securityService: served }); |
| 187 | + const confirm: any = await dispatch(deps, authed(), '/suggested-bindings/sug_1/confirm', 'POST'); |
| 188 | + expect(confirm.response.status).toBe(200); |
| 189 | + expect(confirm.response.body.data).toEqual({ id: 'sug_1', status: 'confirmed' }); |
| 190 | + |
| 191 | + const dismiss: any = await dispatch(deps, authed(), '/suggested-bindings/sug_1/dismiss', 'POST'); |
| 192 | + expect(dismiss.response.status).toBe(200); |
| 193 | + expect(dismiss.response.body.data).toEqual({ id: 'sug_1', status: 'dismissed' }); |
| 194 | + }); |
| 195 | +}); |
0 commit comments