|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#22661] A read that reaches a SECOND object — the record a lookup points at |
| 4 | +// — asks that object's own declared exposure (`enable.apiEnabled` / |
| 5 | +// `enable.apiMethods`, ADR-0049), through the spec's one decision, on a real |
| 6 | +// boot: the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST, auth and |
| 7 | +// analytics layers. |
| 8 | +// |
| 9 | +// ## The reads, and what each must answer |
| 10 | +// |
| 11 | +// - The data door's `$expand` — the list route, the single-record route, the |
| 12 | +// query route's relation map, a second-level entry, and the export door |
| 13 | +// (which expands every lookup to name it). An entry whose target the |
| 14 | +// decision does not serve for `get` answers as an UNEXPANDED lookup: the |
| 15 | +// stored id, the answer the door already gives for a related record the |
| 16 | +// caller may not read (the precedent leg below). |
| 17 | +// - The dataset door's two dimension-label passes. A target the decision does |
| 18 | +// not serve for `get` is not read: the stored id renders, and an `order` on |
| 19 | +// the dimension sorts by it. |
| 20 | +// |
| 21 | +// ## Armed before anything is believed |
| 22 | +// |
| 23 | +// The decision's own answer for each target is read off the data door first: |
| 24 | +// `GET /data/{target}/{id}` answers 404 for the off switch, 405 for a |
| 25 | +// whitelist that does not grant `get`, and 200 for the targets that serve it — |
| 26 | +// and the expansion and label of each target must agree with that answer, for |
| 27 | +// an administrator and a member alike (the decision takes no caller). The |
| 28 | +// served targets are the controls: a door that withheld everything would fail |
| 29 | +// them. |
| 30 | +// |
| 31 | +// Fixtures are synthetic. ⚠️ No test title states a value. |
| 32 | +// `@objectstack/metadata-protocol` and `@objectstack/service-analytics` |
| 33 | +// resolve through their BUILT output here, so a verdict on a change to either |
| 34 | +// is a verdict on its last build. |
| 35 | + |
| 36 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 37 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 38 | +import { secondObjectExposureStack, secondObjectExposureSecurity } from './fixtures/second-object-exposure-fixture.js'; |
| 39 | + |
| 40 | +const MEMBER_EMAIL = 'sox-member@verify.test'; |
| 41 | +const SYS = { context: { isSystem: true } } as const; |
| 42 | + |
| 43 | +/** Each source lookup, the object it points at, and the data door's `get` answer for that object. */ |
| 44 | +const TARGETS = { |
| 45 | + hidden: { object: 'sox_hidden', getStatus: 404 }, |
| 46 | + closed: { object: 'sox_closed', getStatus: 405 }, |
| 47 | + listonly: { object: 'sox_listonly', getStatus: 405 }, |
| 48 | + getonly: { object: 'sox_getonly', getStatus: 200 }, |
| 49 | + open: { object: 'sox_open', getStatus: 200 }, |
| 50 | +} as const; |
| 51 | +type Rel = keyof typeof TARGETS; |
| 52 | +const WITHHELD: Rel[] = ['hidden', 'closed', 'listonly']; |
| 53 | +const SERVED: Rel[] = ['getonly', 'open']; |
| 54 | + |
| 55 | +/** Synthetic display names; each is unique, so finding one in a body means it was served. */ |
| 56 | +const MARK: Record<Rel | 'private', string> = { |
| 57 | + hidden: 'SOXHIDDEN71', |
| 58 | + closed: 'SOXCLOSED72', |
| 59 | + listonly: 'SOXLISTONLY73', |
| 60 | + getonly: 'SOXGETONLY74', |
| 61 | + open: 'SOXOPEN75', |
| 62 | + private: 'SOXPRIVATE76', |
| 63 | +}; |
| 64 | + |
| 65 | +type Row = Record<string, any>; |
| 66 | +const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []); |
| 67 | + |
| 68 | +describe('[#22661] a second-object read serves only a target whose declared exposure serves it', () => { |
| 69 | + let stack: VerifyStack; |
| 70 | + const token: Record<'admin' | 'member', string> = { admin: '', member: '' }; |
| 71 | + /** The stored id each source lookup holds (the same on both source rows, except `hidden`). */ |
| 72 | + const ids: Record<string, string> = {}; |
| 73 | + let sourceId = ''; |
| 74 | + |
| 75 | + beforeAll(async () => { |
| 76 | + stack = await bootStack(secondObjectExposureStack as never, { security: secondObjectExposureSecurity() }); |
| 77 | + token.admin = await stack.signIn(); |
| 78 | + token.member = await stack.signUp(MEMBER_EMAIL); |
| 79 | + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; |
| 80 | + // Two hidden rows whose name order is the reverse of their id order, so a |
| 81 | + // sort by the withheld name and a sort by the stored id disagree. |
| 82 | + await ql.insert('sox_hidden', { id: 'soxh_2', name: `A-${MARK.hidden}` }, SYS); |
| 83 | + await ql.insert('sox_hidden', { id: 'soxh_1', name: `Z-${MARK.hidden}` }, SYS); |
| 84 | + ids.hidden = 'soxh_2'; |
| 85 | + for (const rel of ['closed', 'listonly', 'getonly', 'private'] as const) { |
| 86 | + const row = await ql.insert(`sox_${rel}`, { name: MARK[rel] }, SYS); |
| 87 | + ids[rel] = String(row.id); |
| 88 | + } |
| 89 | + const open = await ql.insert('sox_open', { name: MARK.open, inner: 'soxh_1' }, SYS); |
| 90 | + ids.open = String(open.id); |
| 91 | + const a = await ql.insert('sox_source', { name: 'src-a', amount: 1, ...ids }, SYS); |
| 92 | + sourceId = String(a.id); |
| 93 | + await ql.insert('sox_source', { name: 'src-b', amount: 2, ...ids, hidden: 'soxh_1' }, SYS); |
| 94 | + }, 180_000); |
| 95 | + |
| 96 | + afterAll(async () => { |
| 97 | + await stack?.stop?.(); |
| 98 | + }); |
| 99 | + |
| 100 | + for (const persona of ['admin', 'member'] as const) { |
| 101 | + describe(persona, () => { |
| 102 | + it('ARMED — the data door answers each target\'s own get as its declaration says', async () => { |
| 103 | + for (const rel of [...WITHHELD, ...SERVED]) { |
| 104 | + const res = await stack.apiAs(token[persona], 'GET', `/data/${TARGETS[rel].object}/${ids[rel]}`); |
| 105 | + expect(res.status, `${rel}: ${await res.clone().text()}`).toBe(TARGETS[rel].getStatus); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + const spellings: Array<[string, (rel: Rel) => Promise<Row>]> = [ |
| 110 | + ['the list route', async (rel) => { |
| 111 | + const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source?$expand=${rel}&$filter=${encodeURIComponent(JSON.stringify({ name: 'src-a' }))}`); |
| 112 | + expect(res.status, await res.clone().text()).toBe(200); |
| 113 | + return rowsOf(await res.json())[0]!; |
| 114 | + }], |
| 115 | + ['the single-record route', async (rel) => { |
| 116 | + const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source/${sourceId}?expand=${rel}`); |
| 117 | + expect(res.status, await res.clone().text()).toBe(200); |
| 118 | + const body: any = await res.json(); |
| 119 | + return body.record ?? body; |
| 120 | + }], |
| 121 | + ['the query route relation map', async (rel) => { |
| 122 | + const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', { |
| 123 | + where: { name: 'src-a' }, |
| 124 | + expand: { [rel]: { object: rel, fields: ['name'] } }, |
| 125 | + }); |
| 126 | + expect(res.status, await res.clone().text()).toBe(200); |
| 127 | + return rowsOf(await res.json())[0]!; |
| 128 | + }], |
| 129 | + ]; |
| 130 | + |
| 131 | + for (const [spelling, read] of spellings) { |
| 132 | + it(`$expand on ${spelling}: a withheld target answers as an unexpanded lookup`, async () => { |
| 133 | + for (const rel of WITHHELD) { |
| 134 | + const row = await read(rel); |
| 135 | + expect(row[rel], rel).toBe(ids[rel]); |
| 136 | + } |
| 137 | + }); |
| 138 | + it(`CONTROL $expand on ${spelling}: a served target is expanded`, async () => { |
| 139 | + for (const rel of SERVED) { |
| 140 | + const row = await read(rel); |
| 141 | + expect(row[rel]?.name, rel).toBe(MARK[rel]); |
| 142 | + } |
| 143 | + }); |
| 144 | + } |
| 145 | + |
| 146 | + it('$expand second level: the inner entry into the unexposed object is withheld, the outer one served', async () => { |
| 147 | + const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', { |
| 148 | + where: { name: 'src-a' }, |
| 149 | + expand: { open: { object: 'open', expand: { inner: { object: 'inner' } } } }, |
| 150 | + }); |
| 151 | + expect(res.status, await res.clone().text()).toBe(200); |
| 152 | + const row = rowsOf(await res.json())[0]!; |
| 153 | + expect(row.open?.name).toBe(MARK.open); |
| 154 | + expect(row.open?.inner).toBe('soxh_1'); |
| 155 | + }); |
| 156 | + |
| 157 | + it('the export door names a served target and not a withheld one', async () => { |
| 158 | + const res = await stack.apiAs(token[persona], 'GET', '/data/sox_source/export?format=json'); |
| 159 | + expect(res.status, await res.clone().text()).toBe(200); |
| 160 | + const text = await res.text(); |
| 161 | + for (const rel of SERVED) expect(text, rel).toContain(MARK[rel]); |
| 162 | + for (const rel of WITHHELD) expect(text, rel).not.toContain(MARK[rel]); |
| 163 | + }); |
| 164 | + |
| 165 | + const dataset = (rel: Rel) => ({ |
| 166 | + name: `sox_by_${rel}`, |
| 167 | + label: `By ${rel}`, |
| 168 | + object: 'sox_source', |
| 169 | + dimensions: [{ name: rel, label: rel, field: rel, type: 'lookup' }], |
| 170 | + measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }], |
| 171 | + }); |
| 172 | + const datasetRows = async (rel: Rel, order?: Record<string, 'asc' | 'desc'>) => { |
| 173 | + const res = await stack.apiAs(token[persona], 'POST', '/analytics/dataset/query', { |
| 174 | + dataset: dataset(rel), |
| 175 | + selection: { dimensions: [rel], measures: ['cnt'], ...(order ? { order } : {}) }, |
| 176 | + }); |
| 177 | + expect(res.status, await res.clone().text()).toBe(200); |
| 178 | + return ((await res.json()) as { rows?: Row[] }).rows ?? []; |
| 179 | + }; |
| 180 | + |
| 181 | + it('dataset label pass: a withheld target renders its stored id', async () => { |
| 182 | + for (const rel of ['closed', 'listonly'] as const) { |
| 183 | + expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([ids[rel]]); |
| 184 | + } |
| 185 | + expect((await datasetRows('hidden')).map((r) => r.hidden).sort()).toEqual(['soxh_1', 'soxh_2']); |
| 186 | + }); |
| 187 | + |
| 188 | + it('CONTROL dataset label pass: a served target renders its name', async () => { |
| 189 | + for (const rel of SERVED) { |
| 190 | + expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([MARK[rel]]); |
| 191 | + } |
| 192 | + }); |
| 193 | + |
| 194 | + it('dataset sort-key pass: an order on a withheld dimension sorts by the stored id', async () => { |
| 195 | + // By the withheld names this would read soxh_2 (A-…) first. |
| 196 | + expect((await datasetRows('hidden', { hidden: 'asc' })).map((r) => r.hidden)).toEqual(['soxh_1', 'soxh_2']); |
| 197 | + }); |
| 198 | + }); |
| 199 | + } |
| 200 | + |
| 201 | + it('PRECEDENT — a related record the member may not read answers as an unexpanded lookup, unchanged', async () => { |
| 202 | + const res = await stack.apiAs(token.member, 'GET', `/data/sox_source/${sourceId}?expand=private`); |
| 203 | + expect(res.status, await res.clone().text()).toBe(200); |
| 204 | + const body: any = await res.json(); |
| 205 | + expect((body.record ?? body).private).toBe(ids.private); |
| 206 | + }); |
| 207 | +}); |
0 commit comments