|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0138 D2 — the share-link family answers an anonymous caller with the |
| 5 | + * platform's shared `401 UNAUTHENTICATED`, in the dispatcher's standard |
| 6 | + * envelope, on every route but the two token routes, before any of the family's |
| 7 | + * own logic runs (#24024). |
| 8 | + * |
| 9 | + * ## What was open |
| 10 | + * |
| 11 | + * The published v18 text (changeset `23225-domain-floor-anonymous-deny`) says |
| 12 | + * "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces |
| 13 | + * and on every path the domain does not serve", and that the refusal is "the |
| 14 | + * dispatcher's standard envelope … with the shared message every other |
| 15 | + * dispatcher domain answers with". On a standalone server this family is the |
| 16 | + * `/share-links` surface, and it kept neither half: create, list and revoke |
| 17 | + * each answered 401 with a sentence of their own and no `error.httpStatus`, |
| 18 | + * and no route answered a verb or path the five did not serve, so the adapter |
| 19 | + * did (405 or 404). The decision now sits in the family's door, once, and a |
| 20 | + * per-verb catch-all registered last puts every other request in front of it. |
| 21 | + * |
| 22 | + * ## The census comes from the registration, not from a list typed here |
| 23 | + * |
| 24 | + * Every route is captured off a mock `IHttpServer` as `registerShareLinkRoutes` |
| 25 | + * registers it, and every captured route is driven. A route added to the family |
| 26 | + * later is driven anonymously the day it lands: through the door it answers 401 |
| 27 | + * and this file stays green; mounted on `http` directly, past the door, it |
| 28 | + * reaches the service and this file goes red. |
| 29 | + * |
| 30 | + * ## What each refusal asserts |
| 31 | + * |
| 32 | + * The ADR-0112 envelope — `status`, `error.code` and `error.httpStatus` — the |
| 33 | + * shared message (the published text names it, so its identity is the |
| 34 | + * contract: the exported constant, never a copy), no `data`, and a service and |
| 35 | + * engine that were never called. A bare `toThrow()` would be no pin: the |
| 36 | + * family never threw, it ANSWERED. |
| 37 | + * |
| 38 | + * ## Controls |
| 39 | + * |
| 40 | + * A floor that refused everybody would pass every refusal here. So the two |
| 41 | + * token routes are driven anonymously and reach the token, and every route is |
| 42 | + * driven signed in: the serving routes are served as before, and the catch-all |
| 43 | + * answers the family's own 404 without reaching anything. |
| 44 | + * |
| 45 | + * Which router registration serves which concrete request is the router's |
| 46 | + * question, not this file's: the wire half is |
| 47 | + * `runtime/src/share-links-every-path.hono.integration.test.ts`. |
| 48 | + */ |
| 49 | + |
| 50 | +import { describe, expect, it, vi } from 'vitest'; |
| 51 | +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; |
| 52 | +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; |
| 53 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 54 | +import { registerShareLinkRoutes } from './share-link-routes.js'; |
| 55 | +import type { ShareLinkService } from './share-link-service.js'; |
| 56 | +import type { SharingEngine } from './sharing-service.js'; |
| 57 | + |
| 58 | +const BASE = '/api/v1/share-links'; |
| 59 | +const TOKEN_ROUTES = new Set([`GET ${BASE}/:token/resolve`, `GET ${BASE}/:token/messages`]); |
| 60 | + |
| 61 | +class MockHttp implements IHttpServer { |
| 62 | + routes = new Map<string, RouteHandler>(); |
| 63 | + private add(method: string, path: string, handler: RouteHandler) { |
| 64 | + this.routes.set(`${method} ${path}`, handler); |
| 65 | + } |
| 66 | + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } |
| 67 | + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } |
| 68 | + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } |
| 69 | + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } |
| 70 | + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } |
| 71 | + use() { return this as any; } |
| 72 | + listen() { return Promise.resolve(); } |
| 73 | + close() { return Promise.resolve(); } |
| 74 | + getInstance() { return null; } |
| 75 | +} |
| 76 | + |
| 77 | +const LINK_ROW = { |
| 78 | + id: 'sl_1', |
| 79 | + token: 'tok_abcdefgh', |
| 80 | + object_name: 'ai_conversations', |
| 81 | + record_id: 'conv_1', |
| 82 | + permission: 'view', |
| 83 | + audience: 'anyone', |
| 84 | + expires_at: null, |
| 85 | + label: null, |
| 86 | + created_at: '2026-01-01T00:00:00.000Z', |
| 87 | +}; |
| 88 | + |
| 89 | +/** A service and engine that would SUCCEED on every route, recording every call. */ |
| 90 | +function mount(ctx: ExecutionContext) { |
| 91 | + const service = { |
| 92 | + createLink: vi.fn(async () => ({ ...LINK_ROW })), |
| 93 | + listLinks: vi.fn(async () => [{ ...LINK_ROW }]), |
| 94 | + revokeLink: vi.fn(async () => undefined), |
| 95 | + resolveToken: vi.fn(async () => ({ link: { ...LINK_ROW }, redactFields: [] })), |
| 96 | + }; |
| 97 | + const engine = { |
| 98 | + find: vi.fn(async () => [{ id: 'conv_1' }]), |
| 99 | + getSchema: vi.fn(() => undefined), |
| 100 | + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), |
| 101 | + }; |
| 102 | + const http = new MockHttp(); |
| 103 | + registerShareLinkRoutes(http, service as unknown as ShareLinkService, engine as unknown as SharingEngine, { |
| 104 | + contextFromRequest: () => ctx, |
| 105 | + }); |
| 106 | + const calls = () => [ |
| 107 | + ...Object.entries(service).filter(([, fn]) => fn.mock.calls.length > 0).map(([n]) => `service.${n}`), |
| 108 | + ...(engine.find.mock.calls.length > 0 ? ['engine.find'] : []), |
| 109 | + ]; |
| 110 | + return { http, calls }; |
| 111 | +} |
| 112 | + |
| 113 | +/** A concrete request for each registered pattern. */ |
| 114 | +function requestFor(key: string): IHttpRequest { |
| 115 | + const method = key.slice(0, key.indexOf(' ')); |
| 116 | + const pattern = key.slice(key.indexOf(' ') + 1); |
| 117 | + const params: Record<string, string> = {}; |
| 118 | + let path = pattern; |
| 119 | + if (path.includes(':idOrToken')) { |
| 120 | + params.idOrToken = 'sl_1'; |
| 121 | + path = path.replace(':idOrToken', 'sl_1'); |
| 122 | + } |
| 123 | + if (path.includes(':token')) { |
| 124 | + params.token = 'tok_abcdefgh'; |
| 125 | + path = path.replace(':token', 'tok_abcdefgh'); |
| 126 | + } |
| 127 | + path = path.replace('*', 'abc/def'); |
| 128 | + return { |
| 129 | + params, |
| 130 | + query: {}, |
| 131 | + body: method === 'POST' ? { object: 'ai_conversations', recordId: 'conv_1' } : undefined, |
| 132 | + headers: {}, |
| 133 | + method, |
| 134 | + path, |
| 135 | + } as unknown as IHttpRequest; |
| 136 | +} |
| 137 | + |
| 138 | +async function drive(http: MockHttp, key: string) { |
| 139 | + const handler = http.routes.get(key); |
| 140 | + if (!handler) throw new Error(`no handler for ${key}`); |
| 141 | + const captured: { status: number; body: any } = { status: 200, body: undefined }; |
| 142 | + const res: IHttpResponse = { |
| 143 | + json: vi.fn((data: any) => { captured.body = data; }) as any, |
| 144 | + send: vi.fn() as any, |
| 145 | + status: vi.fn((code: number) => { captured.status = code; return res; }) as any, |
| 146 | + header: vi.fn(() => res) as any, |
| 147 | + }; |
| 148 | + await handler(requestFor(key), res); |
| 149 | + return captured; |
| 150 | +} |
| 151 | + |
| 152 | +const census = [...mount({}).http.routes.keys()]; |
| 153 | +const floored = census.filter((key) => !TOKEN_ROUTES.has(key)); |
| 154 | + |
| 155 | +describe('the share-link census — read off the registration', () => { |
| 156 | + it('registers the five serving routes and one catch-all per verb, registered last', () => { |
| 157 | + expect(census).toEqual([ |
| 158 | + `POST ${BASE}`, |
| 159 | + `GET ${BASE}`, |
| 160 | + `DELETE ${BASE}/:idOrToken`, |
| 161 | + `GET ${BASE}/:token/resolve`, |
| 162 | + `GET ${BASE}/:token/messages`, |
| 163 | + `GET ${BASE}/*`, |
| 164 | + `POST ${BASE}/*`, |
| 165 | + `PUT ${BASE}/*`, |
| 166 | + `PATCH ${BASE}/*`, |
| 167 | + `DELETE ${BASE}/*`, |
| 168 | + ]); |
| 169 | + }); |
| 170 | +}); |
| 171 | + |
| 172 | +describe('ADR-0138 D2 floor — an anonymous caller is refused 401 on every route but the token routes', () => { |
| 173 | + // Both anonymous shapes the plugin's resolver can hand the door: nothing at |
| 174 | + // all (an unresolvable request), and an envelope with no user in it. |
| 175 | + const ANONYMOUS: Array<[string, ExecutionContext]> = [ |
| 176 | + ['an empty context', {}], |
| 177 | + ['a context with no user', { isSystem: false, tenantId: 't_1' }], |
| 178 | + ]; |
| 179 | + for (const [name, ctx] of ANONYMOUS) { |
| 180 | + it.each(floored)(`%s · ${name} → 401 in the dispatcher's envelope, nothing reached`, async (key) => { |
| 181 | + const { http, calls } = mount(ctx); |
| 182 | + const answer = await drive(http, key); |
| 183 | + expect(answer.status).toBe(ANONYMOUS_DENY_STATUS); |
| 184 | + expect(answer.body?.success).toBe(false); |
| 185 | + expect(answer.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); |
| 186 | + expect(answer.body?.error?.httpStatus).toBe(ANONYMOUS_DENY_STATUS); |
| 187 | + expect(answer.body?.error?.message).toBe(ANONYMOUS_DENY_MESSAGE); |
| 188 | + expect(answer.body?.data).toBeUndefined(); |
| 189 | + expect(calls()).toEqual([]); |
| 190 | + }); |
| 191 | + } |
| 192 | + |
| 193 | + it.each([...TOKEN_ROUTES])('%s · anonymous → admitted by the floor and judged by the token', async (key) => { |
| 194 | + const { http, calls } = mount({}); |
| 195 | + const answer = await drive(http, key); |
| 196 | + expect(answer.status).toBe(200); |
| 197 | + expect(answer.body?.success).toBe(true); |
| 198 | + expect(calls()).toContain('service.resolveToken'); |
| 199 | + }); |
| 200 | +}); |
| 201 | + |
| 202 | +describe('CONTROL — a signed-in caller is not refused by the floor', () => { |
| 203 | + it.each(census)('%s · signed in → served, or the catch-all\'s own 404', async (key) => { |
| 204 | + const { http, calls } = mount({ userId: 'u_1', tenantId: 't_1' }); |
| 205 | + const answer = await drive(http, key); |
| 206 | + if (key.endsWith('/*')) { |
| 207 | + expect(answer.status).toBe(404); |
| 208 | + expect(answer.body?.success).toBe(false); |
| 209 | + expect(answer.body?.error?.code).toBe('ENDPOINT_NOT_FOUND'); |
| 210 | + expect(calls()).toEqual([]); |
| 211 | + return; |
| 212 | + } |
| 213 | + expect(answer.status).toBe(key === `POST ${BASE}` ? 201 : 200); |
| 214 | + expect(answer.body?.success).toBe(true); |
| 215 | + expect(calls().length).toBeGreaterThan(0); |
| 216 | + }); |
| 217 | +}); |
0 commit comments