|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23328] The share-link resolve refusal order: ONE table, BOTH mounts. |
| 5 | + * |
| 6 | + * `/share-links/:token/resolve` has two mounts: plugin-sharing's |
| 7 | + * `registerShareLinkRoutes` (the standalone server's) and this package's |
| 8 | + * dispatcher twin `handleShareLinksRequest` (cloud's per-environment kernels, |
| 9 | + * `registerShareLinkRoutes: false`). When `resolveToken` refuses, each mount |
| 10 | + * probes the row to name the refusal, and each carries its own copy of that |
| 11 | + * probe's arm order — `@objectstack/plugin-sharing` is a DEV dependency of |
| 12 | + * this package, so the twin cannot import the plugin's code. This table is |
| 13 | + * what holds the two copies in step. |
| 14 | + * |
| 15 | + * ## The rule it pins |
| 16 | + * |
| 17 | + * For a live link whose audience is `signed_in`, an anonymous caller is |
| 18 | + * answered `401 SIGN_IN_REQUIRED` WHATEVER password it sent. Before sign-in |
| 19 | + * the probe judges the audience, never the password: `resolveToken` refuses |
| 20 | + * that caller on the audience before it verifies any password, so a probe |
| 21 | + * that answered a password arm first could only report whether a password |
| 22 | + * was SENT — and it answered `WRONG_PASSWORD` for the correct one. |
| 23 | + * |
| 24 | + * The oracle property is pinned on its own: an anonymous caller reads the |
| 25 | + * SAME answer, byte for byte, for no password, a wrong one and the correct |
| 26 | + * one. Any difference there, before sign-in, would tell an anonymous holder |
| 27 | + * of the token whether a guessed password is right. |
| 28 | + * |
| 29 | + * A signed-in caller keeps the password arms: `NEEDS_PASSWORD`, |
| 30 | + * `WRONG_PASSWORD`, and the record for the correct password. The controls |
| 31 | + * are unchanged by the rule: a `public` link with a password still answers |
| 32 | + * the password arms to an anonymous caller, an unprotected `public` link |
| 33 | + * still serves, and a revoked link still answers the 410 whoever asks. |
| 34 | + * |
| 35 | + * ## What is real here |
| 36 | + * |
| 37 | + * Both mounts' production entries over ONE `ObjectQL` on |
| 38 | + * `@objectstack/driver-sql` + better-sqlite3 with the shipped |
| 39 | + * `sys_share_link` definition (whose `password_hash` is `internal`, so both |
| 40 | + * probes recover it the fail-closed way), ONE `ShareLinkService` with the |
| 41 | + * production password hasher, and the password sent the way the console's |
| 42 | + * landing page sends it: the `X-Share-Password` header. Refusals are read as |
| 43 | + * the ADR-0112 envelope's `status` + `error.code`. |
| 44 | + */ |
| 45 | + |
| 46 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 47 | +import { ObjectQL } from '@objectstack/objectql'; |
| 48 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 49 | +import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; |
| 50 | +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; |
| 51 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 52 | +import { ShareLinkService, SysShareLink, registerShareLinkRoutes } from '@objectstack/plugin-sharing'; |
| 53 | +import { apiErrorResponse } from '../error-envelope.js'; |
| 54 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 55 | +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; |
| 56 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 57 | +import { handleShareLinksRequest } from './share-links.js'; |
| 58 | + |
| 59 | +const OBJECT = 'refusal_doc'; |
| 60 | +const RECORD = 'doc_1'; |
| 61 | +const VIEWER = 'usr_viewer'; |
| 62 | +const PASSWORD = 'correct horse 23328'; |
| 63 | +const WRONG = 'not the password 23328'; |
| 64 | + |
| 65 | +const TARGET = { |
| 66 | + name: OBJECT, |
| 67 | + label: 'Refusal Doc', |
| 68 | + publicSharing: { enabled: true, allowedAudiences: ['public', 'signed_in'], allowedPermissions: ['view'] }, |
| 69 | + fields: { |
| 70 | + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, |
| 71 | + title: { name: 'title', label: 'Title', type: 'text' }, |
| 72 | + }, |
| 73 | +}; |
| 74 | + |
| 75 | +type Mount = 'plugin route' | 'dispatcher twin'; |
| 76 | +type Caller = 'anonymous' | 'signed in'; |
| 77 | +type Presented = 'no password' | 'a wrong password' | 'the correct password'; |
| 78 | +type LinkKey = 'signed_in + password' | 'public + password' | 'public' | 'revoked signed_in + password'; |
| 79 | +type Answer = { status: number; body: any; headers: Record<string, string> }; |
| 80 | + |
| 81 | +const MOUNTS: readonly Mount[] = ['plugin route', 'dispatcher twin']; |
| 82 | +const PRESENTED: Record<Presented, string | undefined> = { |
| 83 | + 'no password': undefined, |
| 84 | + 'a wrong password': WRONG, |
| 85 | + 'the correct password': PASSWORD, |
| 86 | +}; |
| 87 | + |
| 88 | +/** The dispatcher's REAL thrown-error mapper, as the sibling suites borrow it. */ |
| 89 | +const realErrorFromThrown = (() => { |
| 90 | + const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any); |
| 91 | + return (e: any, fallbackStatus?: number) => dispatcher.errorFromThrown(e, fallbackStatus); |
| 92 | +})(); |
| 93 | + |
| 94 | +function makeDeps(engine: any, svc: any): DomainHandlerDeps { |
| 95 | + const deps: any = { |
| 96 | + resolveService: async (_c: any, name: string) => |
| 97 | + name === SHARE_LINK_SERVICE ? svc : name === 'objectql' ? engine : undefined, |
| 98 | + getRequestKernelService: async (_c: any, name: string) => (name === 'objectql' ? engine : undefined), |
| 99 | + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, ...(meta ? { meta } : {}) } }), |
| 100 | + error: (message: string, httpStatus = 500, details?: any) => apiErrorResponse({ message, httpStatus, details }), |
| 101 | + routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }), |
| 102 | + errorFromThrown: realErrorFromThrown, |
| 103 | + }; |
| 104 | + return deps as DomainHandlerDeps; |
| 105 | +} |
| 106 | + |
| 107 | +/** The smallest `IHttpServer` that keeps the handlers a registrar mounts. */ |
| 108 | +class RouteRecorder implements IHttpServer { |
| 109 | + readonly routes = new Map<string, RouteHandler>(); |
| 110 | + get(path: string, handler: RouteHandler) { this.routes.set(`GET ${path}`, handler); } |
| 111 | + post(path: string, handler: RouteHandler) { this.routes.set(`POST ${path}`, handler); } |
| 112 | + put(path: string, handler: RouteHandler) { this.routes.set(`PUT ${path}`, handler); } |
| 113 | + delete(path: string, handler: RouteHandler) { this.routes.set(`DELETE ${path}`, handler); } |
| 114 | + patch(path: string, handler: RouteHandler) { this.routes.set(`PATCH ${path}`, handler); } |
| 115 | + use() { /* no middleware is mounted by the registrar under test */ } |
| 116 | + async listen() { /* never listens: handlers are driven in-process */ } |
| 117 | +} |
| 118 | + |
| 119 | +const CALLERS: Record<Caller, ExecutionContext> = { |
| 120 | + anonymous: {}, |
| 121 | + 'signed in': { userId: VIEWER }, |
| 122 | +}; |
| 123 | + |
| 124 | +let engine: ObjectQL; |
| 125 | +const tokens = {} as Record<LinkKey, string>; |
| 126 | +let open: (mount: Mount, link: LinkKey, caller: Caller, presented: Presented) => Promise<Answer>; |
| 127 | + |
| 128 | +beforeAll(async () => { |
| 129 | + engine = new ObjectQL(); |
| 130 | + engine.registerDriver( |
| 131 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, |
| 132 | + true, |
| 133 | + ); |
| 134 | + await engine.init(); |
| 135 | + for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); |
| 136 | + await engine.syncSchemas(); |
| 137 | + await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); |
| 138 | + |
| 139 | + const svc = new ShareLinkService({ engine: engine as any }); |
| 140 | + const creator = { isSystem: true, userId: 'usr_creator' } as any; |
| 141 | + const mint = async (audience: 'public' | 'signed_in', password?: string) => |
| 142 | + svc.createLink({ object: OBJECT, recordId: RECORD, audience, ...(password ? { password } : {}) }, creator); |
| 143 | + tokens['signed_in + password'] = (await mint('signed_in', PASSWORD)).token; |
| 144 | + tokens['public + password'] = (await mint('public', PASSWORD)).token; |
| 145 | + tokens.public = (await mint('public')).token; |
| 146 | + const revoked = await mint('signed_in', PASSWORD); |
| 147 | + await svc.revokeLink(revoked.id, creator); |
| 148 | + tokens['revoked signed_in + password'] = revoked.token; |
| 149 | + |
| 150 | + // The plugin mount, once per caller: the family door resolves the caller |
| 151 | + // through `contextFromRequest`, exactly as production wires it. |
| 152 | + const pluginRoutes = {} as Record<Caller, RouteHandler>; |
| 153 | + for (const caller of Object.keys(CALLERS) as Caller[]) { |
| 154 | + const http = new RouteRecorder(); |
| 155 | + registerShareLinkRoutes(http, svc, engine as any, { contextFromRequest: () => CALLERS[caller] }); |
| 156 | + const handler = http.routes.get('GET /api/v1/share-links/:token/resolve'); |
| 157 | + if (!handler) throw new Error('registerShareLinkRoutes mounted no resolve route'); |
| 158 | + pluginRoutes[caller] = handler; |
| 159 | + } |
| 160 | + const deps = makeDeps(engine, svc); |
| 161 | + |
| 162 | + open = async (mount, link, caller, presented) => { |
| 163 | + const token = tokens[link]; |
| 164 | + const password = PRESENTED[presented]; |
| 165 | + const headers: Record<string, string> = password === undefined ? {} : { 'x-share-password': password }; |
| 166 | + if (mount === 'dispatcher twin') { |
| 167 | + const res = await handleShareLinksRequest(deps, `/${token}/resolve`, 'GET', undefined, {}, { |
| 168 | + executionContext: CALLERS[caller], |
| 169 | + request: { headers }, |
| 170 | + } as unknown as HttpProtocolContext); |
| 171 | + if (!res.handled || !res.response) throw new Error('the dispatcher twin did not handle resolve'); |
| 172 | + const r = res.response as { status: number; body: any; headers?: Record<string, string> }; |
| 173 | + return { status: r.status, body: r.body, headers: { ...(r.headers ?? {}) } }; |
| 174 | + } |
| 175 | + const captured: Answer = { status: 200, body: undefined, headers: {} }; |
| 176 | + const res: IHttpResponse = { |
| 177 | + json: (data: any) => { captured.body = data; }, |
| 178 | + send: () => { /* the share-link routes answer JSON only */ }, |
| 179 | + status: (code: number) => { captured.status = code; return res; }, |
| 180 | + header: (name: string, value: string) => { captured.headers[name] = value; return res; }, |
| 181 | + }; |
| 182 | + const req: IHttpRequest = { |
| 183 | + params: { token }, |
| 184 | + query: {}, |
| 185 | + body: undefined, |
| 186 | + headers, |
| 187 | + method: 'GET', |
| 188 | + path: `/api/v1/share-links/${token}/resolve`, |
| 189 | + }; |
| 190 | + await pluginRoutes[caller](req, res); |
| 191 | + return captured; |
| 192 | + }; |
| 193 | +}, 60_000); |
| 194 | + |
| 195 | +afterAll(async () => { |
| 196 | + try { |
| 197 | + await engine?.destroy(); |
| 198 | + } catch { |
| 199 | + /* noop */ |
| 200 | + } |
| 201 | +}); |
| 202 | + |
| 203 | +interface Row { |
| 204 | + link: LinkKey; |
| 205 | + caller: Caller; |
| 206 | + presented: Presented; |
| 207 | + status: number; |
| 208 | + /** The refusal's `error.code`; absent on a row that serves the record. */ |
| 209 | + code?: string; |
| 210 | +} |
| 211 | + |
| 212 | +/** The card's table, then the controls. */ |
| 213 | +const TABLE: readonly Row[] = [ |
| 214 | + // A password AND audience `signed_in`: before sign-in, the audience alone. |
| 215 | + { link: 'signed_in + password', caller: 'anonymous', presented: 'no password', status: 401, code: 'SIGN_IN_REQUIRED' }, |
| 216 | + { link: 'signed_in + password', caller: 'anonymous', presented: 'a wrong password', status: 401, code: 'SIGN_IN_REQUIRED' }, |
| 217 | + { link: 'signed_in + password', caller: 'anonymous', presented: 'the correct password', status: 401, code: 'SIGN_IN_REQUIRED' }, |
| 218 | + // Signed in, the password arms. |
| 219 | + { link: 'signed_in + password', caller: 'signed in', presented: 'no password', status: 401, code: 'NEEDS_PASSWORD' }, |
| 220 | + { link: 'signed_in + password', caller: 'signed in', presented: 'a wrong password', status: 401, code: 'WRONG_PASSWORD' }, |
| 221 | + { link: 'signed_in + password', caller: 'signed in', presented: 'the correct password', status: 200 }, |
| 222 | + // Control: a `public` link with a password keeps the password arms for an anonymous caller. |
| 223 | + { link: 'public + password', caller: 'anonymous', presented: 'no password', status: 401, code: 'NEEDS_PASSWORD' }, |
| 224 | + { link: 'public + password', caller: 'anonymous', presented: 'a wrong password', status: 401, code: 'WRONG_PASSWORD' }, |
| 225 | + { link: 'public + password', caller: 'anonymous', presented: 'the correct password', status: 200 }, |
| 226 | + // Control: an unprotected `public` link serves an anonymous caller. |
| 227 | + { link: 'public', caller: 'anonymous', presented: 'no password', status: 200 }, |
| 228 | + // Control: the 410 arm is not reordered — a revoked link answers it whoever asks. |
| 229 | + { link: 'revoked signed_in + password', caller: 'anonymous', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' }, |
| 230 | + { link: 'revoked signed_in + password', caller: 'signed in', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' }, |
| 231 | +]; |
| 232 | + |
| 233 | +describe.each(MOUNTS)('[#23328] share-link resolve refusal order — the %s', (mount) => { |
| 234 | + it.each(TABLE)('$link, $caller, $presented → $status $code', async (row) => { |
| 235 | + const answer = await open(mount, row.link, row.caller, row.presented); |
| 236 | + const label = `${mount}: ${JSON.stringify(answer.body)}`; |
| 237 | + expect(answer.status, label).toBe(row.status); |
| 238 | + if (row.code) { |
| 239 | + expect(answer.body?.success, label).toBe(false); |
| 240 | + expect(answer.body?.error?.code, label).toBe(row.code); |
| 241 | + } else { |
| 242 | + expect(answer.body?.success, label).toBe(true); |
| 243 | + expect(answer.body?.data?.record?.id, label).toBe(RECORD); |
| 244 | + } |
| 245 | + }); |
| 246 | + |
| 247 | + it('no anonymous password oracle: no, a wrong and the correct password read one identical answer', async () => { |
| 248 | + const none = await open(mount, 'signed_in + password', 'anonymous', 'no password'); |
| 249 | + const wrong = await open(mount, 'signed_in + password', 'anonymous', 'a wrong password'); |
| 250 | + const correct = await open(mount, 'signed_in + password', 'anonymous', 'the correct password'); |
| 251 | + expect(correct, 'the correct password against a wrong one').toEqual(wrong); |
| 252 | + expect(correct, 'the correct password against none').toEqual(none); |
| 253 | + }); |
| 254 | +}); |
0 commit comments