|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #15685 — `GET /api/v1/meta/:type/:name/references` can refuse in two ways, |
| 4 | +// and until this file the two answers agreed on neither the envelope nor the |
| 5 | +// message. Measured on one boot, through the REAL route: |
| 6 | +// |
| 7 | +// A the protocol cannot answer for this TARGET type (#9327, `field`) |
| 8 | +// 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} |
| 9 | +// B the resolved kernel has no `findReferencesToMeta` at all (#9326) |
| 10 | +// 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} |
| 11 | +// |
| 12 | +// ── What the divergence COST, which is why this is pinned at the wire ────── |
| 13 | +// |
| 14 | +// This door backs the admin "Used by" panel, whose empty case renders "Nothing |
| 15 | +// in the metadata graph points at this item. Safe to delete." to an operator |
| 16 | +// whose next click is a delete. ADR-0110 D3 (#8896) exists so that "the |
| 17 | +// question was never asked" is never answered as "nothing depends on it", and |
| 18 | +// A's message is the half that steers the operator away from the first reading |
| 19 | +// — `findReferencesToMeta` says so in as many words ("The message is |
| 20 | +// prescriptive per ADR-0110 D3: it names the answerable question"). It names |
| 21 | +// the question that IS answerable: ask the owning OBJECT. On the wire that |
| 22 | +// instruction had been replaced by "Internal server error". |
| 23 | +// |
| 24 | +// Second, `body.error.code` read on B and `undefined` on A — and the door's own |
| 25 | +// comment on the B branch warns against exactly that dialect ("never the |
| 26 | +// bare-string or sibling-`code` dialects, which make `body.error.code` read |
| 27 | +// `undefined`"). One route was violating its own written rule at its other |
| 28 | +// exit. |
| 29 | +// |
| 30 | +// ── Why this file exists rather than another assertion in the org-scope pin ─ |
| 31 | +// |
| 32 | +// `rest-server-meta-read-org-scope.test.ts` already drives this route's #9327 |
| 33 | +// refusal — and reads the code through BOTH dialects on purpose, so it is GREEN |
| 34 | +// under either shape. That is correct for what it measures (the refusal's code |
| 35 | +// and status survive a SCOPE repair) and useless as a red/green criterion for |
| 36 | +// this one. The two facts below are not that file's subject, and they need a |
| 37 | +// boot WITHOUT `findReferencesToMeta` beside a boot with it, which its harness |
| 38 | +// does not build. So: a file of its own, and the assertions are POSITIONAL — |
| 39 | +// `body.error.code`, `body.error.message` — because position is half the |
| 40 | +// finding. |
| 41 | + |
| 42 | +import { describe, it, expect } from 'vitest'; |
| 43 | +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; |
| 44 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 45 | +import { RestServer } from './rest-server.js'; |
| 46 | + |
| 47 | +const META = '/api/v1/meta'; |
| 48 | + |
| 49 | +/** The unanswerable target (#9327) and an answerable one, for the control. */ |
| 50 | +const UNANSWERABLE_TARGET = 'field'; |
| 51 | +const UNANSWERABLE_NAME = 'account.owner'; |
| 52 | +const ANSWERABLE_TARGET = 'object'; |
| 53 | + |
| 54 | +function mockRes() { |
| 55 | + const res: any = { statusCode: 200, _body: undefined }; |
| 56 | + res.status = (c: number) => { res.statusCode = c; return res; }; |
| 57 | + res.json = (b: any) => { res._body = b; return res; }; |
| 58 | + res.send = (b: any) => { res._body = b; return res; }; |
| 59 | + res.header = () => res; |
| 60 | + res.setHeader = () => res; |
| 61 | + res.end = () => res; |
| 62 | + return res; |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * The narrowest engine this route's reads bottom out on: every metadata lookup |
| 67 | + * answers "no rows". Deliberately empty rather than seeded — nothing below |
| 68 | + * asserts on reference CONTENT, and an empty store is what makes the |
| 69 | + * answerable-target control's `{ references: [] }` unambiguous. |
| 70 | + * |
| 71 | + * ⛔ READ-ONLY on purpose: no `delete`, `update` or `insert` member exists, |
| 72 | + * because nothing this file drives writes. A door that started writing here |
| 73 | + * would fail on the missing member rather than silently exercise a write double |
| 74 | + * nobody pinned — and this fixture therefore adds no new `delete()` double for |
| 75 | + * `check:engine-double-contract` to police. |
| 76 | + * |
| 77 | + * The `registry` member is not optional decoration: `getMetaItems` reads |
| 78 | + * `registry.listItems` on every source type the reference sweep walks, and an |
| 79 | + * engine without it answers 500 — which would have read as "the door refuses |
| 80 | + * answerable targets too", i.e. it would have silently voided the control this |
| 81 | + * fixture exists to provide. |
| 82 | + */ |
| 83 | +function emptyEngine(): any { |
| 84 | + return { |
| 85 | + find: async () => [], |
| 86 | + findOne: async () => null, |
| 87 | + count: async () => 0, |
| 88 | + aggregate: async () => [], |
| 89 | + registry: { |
| 90 | + listItems: () => [], |
| 91 | + getItem: () => undefined, |
| 92 | + getObject: () => undefined, |
| 93 | + getPackage: () => undefined, |
| 94 | + getArtifactItem: () => undefined, |
| 95 | + isPackageDisabled: () => false, |
| 96 | + }, |
| 97 | + }; |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * One boot on the REAL route table. `mutate` is applied to the real protocol |
| 102 | + * before the routes are registered, which is how the two refusals and the |
| 103 | + * fault control are reached: by changing what the PRODUCER does, never by |
| 104 | + * stubbing the door. |
| 105 | + */ |
| 106 | +function boot(mutate: (protocol: any) => void = () => {}) { |
| 107 | + const protocol: any = new ObjectStackProtocolImplementation(emptyEngine(), () => new Map()); |
| 108 | + protocol.getDiscovery = async () => ({ |
| 109 | + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, |
| 110 | + }); |
| 111 | + mutate(protocol); |
| 112 | + |
| 113 | + const rest = new RestServer( |
| 114 | + { get() {}, post() {}, put() {}, patch() {}, delete() {}, use() {} } as any, |
| 115 | + protocol as any, |
| 116 | + { api: { requireAuth: false } } as any, |
| 117 | + ); |
| 118 | + (rest as any).resolveExecCtx = async () => ({ |
| 119 | + userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: 'org_alpha', |
| 120 | + }); |
| 121 | + rest.registerRoutes(); |
| 122 | + |
| 123 | + return async (type: string, name: string) => { |
| 124 | + const route = (rest as any).getRoutes().find( |
| 125 | + (r: any) => r.method === 'GET' && r.path === `${META}/:type/:name/references`, |
| 126 | + ); |
| 127 | + if (!route) throw new Error('route not registered: GET /meta/:type/:name/references'); |
| 128 | + const res = mockRes(); |
| 129 | + let thrown: any; |
| 130 | + try { |
| 131 | + await route.handler( |
| 132 | + { method: 'GET', path: '', params: { type, name }, query: {}, headers: {}, body: {} } as any, |
| 133 | + res, |
| 134 | + ); |
| 135 | + } catch (err) { thrown = err; } |
| 136 | + return { status: res.statusCode, body: res._body as any, thrown }; |
| 137 | + }; |
| 138 | +} |
| 139 | + |
| 140 | +/** Refusal A: the real protocol, asked for a target it cannot answer for. */ |
| 141 | +const refusalA = () => boot()(UNANSWERABLE_TARGET, UNANSWERABLE_NAME); |
| 142 | + |
| 143 | +/** Refusal B: a kernel whose resolved protocol has no such method at all. */ |
| 144 | +const refusalB = () => boot((p) => { p.findReferencesToMeta = undefined; })( |
| 145 | + ANSWERABLE_TARGET, 'account', |
| 146 | +); |
| 147 | + |
| 148 | +describe('#15685 the /references door answers its two refusals in ONE envelope', () => { |
| 149 | + // ── anti-vacuity ────────────────────────────────────────────────────── |
| 150 | + // |
| 151 | + // Every assertion below is about a REFUSAL, so a door that refused |
| 152 | + // everything would satisfy them all. It does not: |
| 153 | + it('control — an ANSWERABLE target on the same harness still answers 200', async () => { |
| 154 | + const answered = await boot()(ANSWERABLE_TARGET, 'account'); |
| 155 | + expect(answered.thrown, `the door threw: ${answered.thrown?.message}`).toBeUndefined(); |
| 156 | + expect({ status: answered.status, body: answered.body }).toEqual({ |
| 157 | + status: 200, body: { references: [] }, |
| 158 | + }); |
| 159 | + }); |
| 160 | + |
| 161 | + // ── ① the prescription reaches the caller ───────────────────────────── |
| 162 | + describe('① refusal A keeps the prescriptive ADR-0110 D3 sentence', () => { |
| 163 | + it('names the answerable question instead of "Internal server error"', async () => { |
| 164 | + const refused = await refusalA(); |
| 165 | + expect(refused.thrown, `the door threw: ${refused.thrown?.message}`).toBeUndefined(); |
| 166 | + expect(refused.status).toBe(501); |
| 167 | + |
| 168 | + const message = refused.body?.error?.message; |
| 169 | + // The WHOLE point of the message: what to ask INSTEAD. Anchored on |
| 170 | + // the URL it prescribes, derived from the composite key's owner — |
| 171 | + // an operator can act on this sentence and on no other. |
| 172 | + expect(message).toContain( |
| 173 | + `Ask the owning object instead: GET /api/v1/meta/object/account/references`, |
| 174 | + ); |
| 175 | + // And the fact the empty answer would have misreported, spelled out |
| 176 | + // rather than left to be inferred from a 501. |
| 177 | + expect(message).toContain('cannot be computed'); |
| 178 | + |
| 179 | + // The direct statement of the regression, not merely its absence: |
| 180 | + // the generic fault text is what this used to be, everywhere in the |
| 181 | + // body, and it is gone. |
| 182 | + expect(JSON.stringify(refused.body)).not.toContain(INTERNAL_ERROR_MESSAGE); |
| 183 | + }); |
| 184 | + |
| 185 | + it('control — the generic text really is what a withheld fault says', () => { |
| 186 | + // Without this, the assertion above could be passing because |
| 187 | + // `INTERNAL_ERROR_MESSAGE` is some string that never appears |
| 188 | + // anywhere. It is the exact text refusal A used to ship. |
| 189 | + expect(INTERNAL_ERROR_MESSAGE).toBe('Internal server error'); |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + // ── ② the code reads the same way on BOTH refusals ──────────────────── |
| 194 | + describe('② `body.error.code` reads the same way on both refusals', () => { |
| 195 | + it('both are the ADR-0112 NESTED envelope, with the code in ONE place', async () => { |
| 196 | + const [a, b] = await Promise.all([refusalA(), refusalB()]); |
| 197 | + |
| 198 | + expect([a.status, b.status]).toEqual([501, 501]); |
| 199 | + // The positional claim, stated as ONE comparison so a repair that |
| 200 | + // fixed one exit and not the other cannot read as green. |
| 201 | + expect([a.body?.error?.code, b.body?.error?.code]) |
| 202 | + .toEqual(['NOT_IMPLEMENTED', 'NOT_IMPLEMENTED']); |
| 203 | + // …and the sibling-`code` dialect the door's own comment names is |
| 204 | + // absent from BOTH, which is the other half of "one place". |
| 205 | + expect([a.body?.code, b.body?.code]).toEqual([undefined, undefined]); |
| 206 | + // Neither answers the bare-string dialect either. |
| 207 | + expect([typeof a.body?.error, typeof b.body?.error]).toEqual(['object', 'object']); |
| 208 | + }); |
| 209 | + |
| 210 | + it('and each still carries its OWN message — converged envelope, not converged prose', async () => { |
| 211 | + const [a, b] = await Promise.all([refusalA(), refusalB()]); |
| 212 | + expect(b.body?.error?.message).toBe( |
| 213 | + 'protocol.findReferencesToMeta() is not available in this kernel', |
| 214 | + ); |
| 215 | + expect(a.body?.error?.message).not.toBe(b.body?.error?.message); |
| 216 | + }); |
| 217 | + }); |
| 218 | + |
| 219 | + // ── ③ the arm is a REFUSAL relay, not "5xx prose is public now" ─────── |
| 220 | + describe('③ controls — a genuine fault is still withheld and still flat', () => { |
| 221 | + it('a producer-declared 503 keeps the withheld generic answer', async () => { |
| 222 | + // The `sys_metadata` outage class (#8896): `getMetaItems` raises it |
| 223 | + // through this same call, and its message can carry driver |
| 224 | + // internals. #5582/#11718 withhold it, and this repair must not |
| 225 | + // have widened that by a byte. |
| 226 | + const drive = boot((p) => { |
| 227 | + p.findReferencesToMeta = async () => { |
| 228 | + throw Object.assign( |
| 229 | + new Error('pg: connection to 10.0.0.7:5432 refused (password=hunter2)'), |
| 230 | + { status: 503, code: 'SERVICE_UNAVAILABLE' }, |
| 231 | + ); |
| 232 | + }; |
| 233 | + }); |
| 234 | + const refused = await drive(ANSWERABLE_TARGET, 'account'); |
| 235 | + expect(refused.status).toBe(503); |
| 236 | + expect(refused.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'SERVICE_UNAVAILABLE' }); |
| 237 | + expect(JSON.stringify(refused.body)).not.toContain('hunter2'); |
| 238 | + }); |
| 239 | + |
| 240 | + it('a 501 declaring a code this door does not publish stays on the fault terminal', async () => { |
| 241 | + // The stated boundary of the arm, pinned so it is a DECISION rather |
| 242 | + // than an accident: the re-dress is keyed to the one refusal code |
| 243 | + // this route publishes, which is also what keeps an unregistered |
| 244 | + // producer spelling (#9232) off the nested exit by construction. |
| 245 | + const drive = boot((p) => { |
| 246 | + p.findReferencesToMeta = async () => { |
| 247 | + throw Object.assign(new Error('some other 501'), { |
| 248 | + status: 501, code: 'SOMETHING_ELSE', |
| 249 | + }); |
| 250 | + }; |
| 251 | + }); |
| 252 | + const refused = await drive(ANSWERABLE_TARGET, 'account'); |
| 253 | + expect(refused.status).toBe(501); |
| 254 | + expect(refused.body?.error).toBe(INTERNAL_ERROR_MESSAGE); |
| 255 | + }); |
| 256 | + }); |
| 257 | +}); |
0 commit comments