|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#16525] WHICH organization value `getMetaItemCached` folds into the ETag — |
| 5 | + * the SUPPLIED request member, or the EFFECTIVE scope the registry read gate |
| 6 | + * returns — and why the answer is not the defect the card feared. |
| 7 | + * |
| 8 | + * ── The question, and the measurement ───────────────────────────────────── |
| 9 | + * |
| 10 | + * The card put two facts side by side. `organizationIdForMetaRead(type, org)` |
| 11 | + * answers `undefined` for a supplied organization whenever the registry |
| 12 | + * declares `allowOrgOverride: false`, and `getMetaItem` then resolves |
| 13 | + * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a |
| 14 | + * gated-away organization is served the env-wide record. `getMetaItemCached` |
| 15 | + * delegates to that. ⇒ If the ETag names the SUPPLIED organization while the |
| 16 | + * body is the env-wide representation, the validator states a scope that was |
| 17 | + * never served. |
| 18 | + * |
| 19 | + * MEASURED, and the answer is the SUPPLIED member: |
| 20 | + * |
| 21 | + * const scope = [ |
| 22 | + * request.organizationId ? `org:${request.organizationId}` : undefined, |
| 23 | + * request.locale || undefined, |
| 24 | + * ].filter(...); |
| 25 | + * |
| 26 | + * §1 reproduces the divergence end to end — `object` is `allowOrgOverride: |
| 27 | + * false`, a supplied organization is gated away, the env-wide record is |
| 28 | + * served, and the validator still names the organization. |
| 29 | + * |
| 30 | + * ── ⭐ Why that is a COST and not a correctness fault ────────────────────── |
| 31 | + * |
| 32 | + * Because `content` — the serialized document actually being sent — is inside |
| 33 | + * the same hash. The validator is therefore a function of the bytes, not only |
| 34 | + * of the declared scope, so the harm the card names ("a validator claiming to |
| 35 | + * describe an org-scoped representation that was never served") cannot reach |
| 36 | + * a caller: a `304` is answered only on an exact match of a hash that covers |
| 37 | + * those bytes, so a caller is only ever pinned to the representation IT |
| 38 | + * previously received. §3 pins that, and it is the half a future change is |
| 39 | + * most likely to break silently — hashing a cheap version marker instead of |
| 40 | + * the document would leave §1 and §2 green and destroy the whole argument. |
| 41 | + * |
| 42 | + * What is left is validator FRAGMENTATION: N organizations reading one |
| 43 | + * env-wide document through a non-overridable type hold N validators for |
| 44 | + * byte-identical content. Wasteful, not wrong. |
| 45 | + * |
| 46 | + * ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ──────────────────── |
| 47 | + * |
| 48 | + * Folding the EFFECTIVE value instead would merge those validators and make |
| 49 | + * the declared scope true. It is deliberately NOT done here: it changes every |
| 50 | + * published ETag that carries an organization (one forced miss per caller per |
| 51 | + * deploy) and buys nothing at the only production door, which already supplies |
| 52 | + * the effective value (pinned in `@objectstack/rest`, |
| 53 | + * `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read |
| 54 | + * #16525 before making it green: it means the fold moved, which is a decision, |
| 55 | + * not a regression. |
| 56 | + * |
| 57 | + * ── Why the observation channel is a BODY-vs-VALIDATOR pair ─────────────── |
| 58 | + * |
| 59 | + * Asserting an ETag literal would pin the hash function, which is not what |
| 60 | + * this card is about. Every assertion below compares two reads that differ in |
| 61 | + * exactly one input, and asserts the served BODIES alongside the validators — |
| 62 | + * so an ETag difference can never be attributed to a body difference nobody |
| 63 | + * checked, and a body difference can never hide behind a validator nobody |
| 64 | + * read. |
| 65 | + */ |
| 66 | + |
| 67 | +import { describe, expect, it } from 'vitest'; |
| 68 | +import { organizationIdForMetaRead } from '@objectstack/metadata-core'; |
| 69 | +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
| 70 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 71 | + |
| 72 | +const ORG = 'org_acme'; |
| 73 | +const OTHER_ORG = 'org_globex'; |
| 74 | + |
| 75 | +/** `allowOrgOverride: false` — a supplied organization is gated away. */ |
| 76 | +const NON_OVERRIDABLE = 'object'; |
| 77 | + |
| 78 | +/** `allowOrgOverride: true` — a supplied organization survives the gate. */ |
| 79 | +const OVERRIDABLE = 'view'; |
| 80 | + |
| 81 | +interface StoredRow { |
| 82 | + id: string; |
| 83 | + type: string; |
| 84 | + name: string; |
| 85 | + organization_id: string | null; |
| 86 | + package_id: string | null; |
| 87 | + state: string; |
| 88 | + metadata: string; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * A row whose `label` names the partition it came from, so a served document |
| 93 | + * says which row answered without the assertion having to guess. |
| 94 | + */ |
| 95 | +const storedRow = ( |
| 96 | + type: string, |
| 97 | + name: string, |
| 98 | + extra: Partial<StoredRow> = {}, |
| 99 | +): StoredRow => ({ |
| 100 | + id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, |
| 101 | + type, |
| 102 | + name, |
| 103 | + organization_id: null, |
| 104 | + package_id: null, |
| 105 | + state: 'active', |
| 106 | + metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), |
| 107 | + ...extra, |
| 108 | +}); |
| 109 | + |
| 110 | +/** |
| 111 | + * The engine double: `findOne` over a row table, plus the registry surface the |
| 112 | + * single-item read path touches on its way past the overlay. |
| 113 | + * |
| 114 | + * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path |
| 115 | + * under test issues exactly one verb, and a double declaring verbs no case |
| 116 | + * exercises would owe `check:engine-double-contract` a dispatch contract that |
| 117 | + * protects nothing. Same shape the sibling org-read-gate pin drives. |
| 118 | + */ |
| 119 | +function makeHarness(rows: StoredRow[]) { |
| 120 | + const engine: any = { |
| 121 | + async findOne(table: string, opts?: { where?: Record<string, unknown> }) { |
| 122 | + if (table !== 'sys_metadata') return undefined; |
| 123 | + const where = opts?.where ?? {}; |
| 124 | + // `check:where-matcher` — a hand-written matcher with no combinator |
| 125 | + // branch reads `$and` as a field name and answers the wrong |
| 126 | + // question rather than failing. Refuse the shape this double does |
| 127 | + // not implement, matching the sibling doubles' convention. |
| 128 | + for (const k of Object.keys(where)) { |
| 129 | + if (k.startsWith('$')) { |
| 130 | + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); |
| 131 | + } |
| 132 | + } |
| 133 | + return rows.find((r) => |
| 134 | + Object.entries(where).every(([k, v]) => { |
| 135 | + if (v === undefined) return true; |
| 136 | + return (r as unknown as Record<string, unknown>)[k] === v; |
| 137 | + }), |
| 138 | + ); |
| 139 | + }, |
| 140 | + registry: { |
| 141 | + registerItem: () => undefined, |
| 142 | + registerObject: () => undefined, |
| 143 | + listItems: () => [], |
| 144 | + getItem: () => undefined, |
| 145 | + getObject: () => undefined, |
| 146 | + getPackage: () => undefined, |
| 147 | + getArtifactItem: () => undefined, |
| 148 | + isPackageDisabled: () => false, |
| 149 | + applyNavContributions: (app: unknown) => app, |
| 150 | + }, |
| 151 | + }; |
| 152 | + return new ObjectStackProtocolImplementation(engine, () => new Map()) as any; |
| 153 | +} |
| 154 | + |
| 155 | +/** The label the served document carries — `'env …'` or `'org_acme …'`. */ |
| 156 | +const servedLabel = (res: any): string => res?.data?.label; |
| 157 | + |
| 158 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 159 | +// §0 — the registry facts every section below rests on, read not assumed |
| 160 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 161 | + |
| 162 | +describe('§0 the fixture types really are on opposite sides of the read gate', () => { |
| 163 | + it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => { |
| 164 | + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE); |
| 165 | + expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined(); |
| 166 | + expect(entry!.allowOrgOverride).toBe(false); |
| 167 | + expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined(); |
| 168 | + }); |
| 169 | + |
| 170 | + it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => { |
| 171 | + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE); |
| 172 | + expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined(); |
| 173 | + expect(entry!.allowOrgOverride).toBe(true); |
| 174 | + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); |
| 175 | + }); |
| 176 | +}); |
| 177 | + |
| 178 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 179 | +// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member. |
| 180 | +// ⚠️ Characterization. If this reddens, read #16525 before greening it. |
| 181 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 182 | + |
| 183 | +describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => { |
| 184 | + it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => { |
| 185 | + // A pre-#6190 phantom org row sits beside the env-wide one. The gate is |
| 186 | + // what keeps it out of the response; without it this case would prove |
| 187 | + // nothing about the ETag, because the two reads would differ in body. |
| 188 | + const protocol = makeHarness([ |
| 189 | + storedRow(NON_OVERRIDABLE, 'customer'), |
| 190 | + storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }), |
| 191 | + ]); |
| 192 | + |
| 193 | + const supplied = await protocol.getMetaItemCached({ |
| 194 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 195 | + }); |
| 196 | + const orgless = await protocol.getMetaItemCached({ |
| 197 | + type: NON_OVERRIDABLE, name: 'customer', |
| 198 | + }); |
| 199 | + |
| 200 | + // ⭐ The control that makes the validator assertion mean anything: the |
| 201 | + // gate held, so BOTH reads were answered by the env-wide row and the |
| 202 | + // phantom was not served. |
| 203 | + expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer'); |
| 204 | + expect(servedLabel(orgless)).toBe('env customer'); |
| 205 | + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); |
| 206 | + |
| 207 | + // ⇒ Byte-identical representations, different validators. The scope |
| 208 | + // component is the request member, unreduced by the read gate. |
| 209 | + expect( |
| 210 | + supplied.etag.value, |
| 211 | + 'the ETag no longer distinguishes a gated-away organization — see #16525', |
| 212 | + ).not.toBe(orgless.etag.value); |
| 213 | + }); |
| 214 | + |
| 215 | + it('two organizations gated away from the same document hold two validators', async () => { |
| 216 | + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); |
| 217 | + |
| 218 | + const a = await protocol.getMetaItemCached({ |
| 219 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 220 | + }); |
| 221 | + const b = await protocol.getMetaItemCached({ |
| 222 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, |
| 223 | + }); |
| 224 | + |
| 225 | + expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data)); |
| 226 | + // The fragmentation the card names. Wasteful, and harmless for the |
| 227 | + // reason §3 pins. |
| 228 | + expect(a.etag.value).not.toBe(b.etag.value); |
| 229 | + }); |
| 230 | +}); |
| 231 | + |
| 232 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 233 | +// §2 — (b) REACHABILITY of the fallback the fold was written against |
| 234 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 235 | + |
| 236 | +describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => { |
| 237 | + /** |
| 238 | + * The fold's own comment argued from "any FUTURE path that resolves an org |
| 239 | + * row but falls back to the env-wide body". That path is the `??` in |
| 240 | + * `getMetaItem` and it runs today on an OVERRIDABLE type whose |
| 241 | + * organization simply has no row — reachable through the production |
| 242 | + * cached door, which forwards the organization for exactly these types. |
| 243 | + */ |
| 244 | + it('an overridable type with no org row serves env-wide under an org-named validator', async () => { |
| 245 | + const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]); |
| 246 | + |
| 247 | + const supplied = await protocol.getMetaItemCached({ |
| 248 | + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, |
| 249 | + }); |
| 250 | + const orgless = await protocol.getMetaItemCached({ |
| 251 | + type: OVERRIDABLE, name: 'account_list', |
| 252 | + }); |
| 253 | + |
| 254 | + // The organization survived the gate here — and still resolved nothing, |
| 255 | + // so the env-wide row answered. |
| 256 | + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); |
| 257 | + expect(servedLabel(supplied)).toBe('env account_list'); |
| 258 | + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); |
| 259 | + expect(supplied.etag.value).not.toBe(orgless.etag.value); |
| 260 | + }); |
| 261 | + |
| 262 | + it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => { |
| 263 | + // ⭐ Without this control the case above is indistinguishable from a |
| 264 | + // harness that can never resolve an org row at all. |
| 265 | + const protocol = makeHarness([ |
| 266 | + storedRow(OVERRIDABLE, 'account_list'), |
| 267 | + storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }), |
| 268 | + ]); |
| 269 | + |
| 270 | + const supplied = await protocol.getMetaItemCached({ |
| 271 | + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, |
| 272 | + }); |
| 273 | + expect(servedLabel(supplied)).toBe(`${ORG} account_list`); |
| 274 | + }); |
| 275 | +}); |
| 276 | + |
| 277 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 278 | +// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash |
| 279 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 280 | + |
| 281 | +describe('§3 the validator is a function of the served bytes, not of the scope alone', () => { |
| 282 | + it('an unchanged document answers 304 to the validator it issued', async () => { |
| 283 | + // ⭐ The control that must FIRE. Every assertion below reads "no 304"; |
| 284 | + // if the 304 arm were unreachable in this harness they would all pass |
| 285 | + // for the wrong reason. |
| 286 | + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; |
| 287 | + const protocol = makeHarness(rows); |
| 288 | + |
| 289 | + const first = await protocol.getMetaItemCached({ |
| 290 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 291 | + }); |
| 292 | + const again = await protocol.getMetaItemCached({ |
| 293 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 294 | + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, |
| 295 | + }); |
| 296 | + |
| 297 | + expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true); |
| 298 | + }); |
| 299 | + |
| 300 | + it('a changed document never answers 304, though the scope is unchanged', async () => { |
| 301 | + // This is the invariant the whole "harmless" argument rests on. A hash |
| 302 | + // over the scope and a cheap version marker — a plausible optimization — |
| 303 | + // leaves §1 and §2 green and turns this red. |
| 304 | + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; |
| 305 | + const protocol = makeHarness(rows); |
| 306 | + |
| 307 | + const first = await protocol.getMetaItemCached({ |
| 308 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 309 | + }); |
| 310 | + |
| 311 | + rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' }); |
| 312 | + |
| 313 | + const after = await protocol.getMetaItemCached({ |
| 314 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 315 | + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, |
| 316 | + }); |
| 317 | + |
| 318 | + expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false); |
| 319 | + expect(servedLabel(after)).toBe('env customer REVISED'); |
| 320 | + expect(after.etag.value).not.toBe(first.etag.value); |
| 321 | + }); |
| 322 | + |
| 323 | + it("one organization's validator is never honoured for another's request", async () => { |
| 324 | + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); |
| 325 | + |
| 326 | + const mine = await protocol.getMetaItemCached({ |
| 327 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, |
| 328 | + }); |
| 329 | + const theirs = await protocol.getMetaItemCached({ |
| 330 | + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, |
| 331 | + cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` }, |
| 332 | + }); |
| 333 | + |
| 334 | + expect(theirs.notModified).toBe(false); |
| 335 | + expect(servedLabel(theirs)).toBe('env customer'); |
| 336 | + }); |
| 337 | +}); |
0 commit comments