|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { |
| 5 | + API_PRIMITIVES, |
| 6 | + apiExposureDenialReason, |
| 7 | + checkManagedApiMethodAffordances, |
| 8 | + effectiveOperationsArray, |
| 9 | + resolveEffectiveApiMethods, |
| 10 | + type EnableLike, |
| 11 | +} from '@objectstack/spec/data'; |
| 12 | +import { REGISTERED_ERROR_CODES } from '@objectstack/spec/api'; |
| 13 | +import { SysWebhook } from './sys-webhook.object.js'; |
| 14 | + |
| 15 | +/** |
| 16 | + * #9756 — `sys_webhook`'s data-API exposure, and the honest size of it. |
| 17 | + * |
| 18 | + * Three cards (#7799, #7986, #8025 option 2) each observed that this object |
| 19 | + * declared no `enable` block and named narrowing its read surface as the next |
| 20 | + * step; none of them owned the line, so it was never written. #9756's own |
| 21 | + * mandate was to measure the consumers BEFORE narrowing anything, and the |
| 22 | + * measurement is what this file pins — including the part that is easy to lose: |
| 23 | + * |
| 24 | + * ⛔ the declaration that landed narrows NOTHING. |
| 25 | + * |
| 26 | + * Every primitive is required by a real consumer, so the authored set is all |
| 27 | + * six, whose effective closure is identical to the one the absent block already |
| 28 | + * produced. The value delivered is that the posture is now a decision on the |
| 29 | + * record rather than a default nobody wrote down — not a reduction in what is |
| 30 | + * reachable. The `narrows nothing` block below is the pin that keeps a later |
| 31 | + * reader (or a survey grepping for `enable:`) from concluding otherwise, and it |
| 32 | + * is the assertion the ablation flips. |
| 33 | + */ |
| 34 | + |
| 35 | +/** The census (#9756). Each row is a consumer that reaches this object through a GATED surface. */ |
| 36 | +const CENSUS: ReadonlyArray<{ consumer: string; via: string; operation: string; bulkChild?: string }> = [ |
| 37 | + // Setup/Studio console — `nav_webhooks` + the object's four list views. |
| 38 | + { consumer: 'console list views', via: 'REST GET /data/sys_webhook', operation: 'list' }, |
| 39 | + { consumer: 'console record detail', via: 'REST GET /data/sys_webhook/:id', operation: 'get' }, |
| 40 | + // `userActions: { create, edit, delete }` — this object is an admin authoring surface. |
| 41 | + { consumer: 'console create', via: 'REST POST /data/sys_webhook', operation: 'create' }, |
| 42 | + { consumer: 'console edit', via: 'REST PATCH /data/sys_webhook/:id', operation: 'update' }, |
| 43 | + { consumer: 'console delete', via: 'REST DELETE /data/sys_webhook/:id', operation: 'delete' }, |
| 44 | + // #4639 — a predicate write over sys_webhook ("deactivate every webhook on an |
| 45 | + // object") is a supported operator gesture; `AutoEnqueuer.handleSelfHealEvent` |
| 46 | + // carries a `data.records.*` branch built expressly for it. Both *Many routes |
| 47 | + // gate on the `bulk` primitive AND the batched child verb. |
| 48 | + { consumer: 'operator predicate deactivate (#4639)', via: 'REST updateMany', operation: 'bulk', bulkChild: 'update' }, |
| 49 | + { consumer: 'operator predicate delete (#4639)', via: 'REST deleteMany', operation: 'bulk', bulkChild: 'delete' }, |
| 50 | + { consumer: 'console bulk create', via: 'REST createMany', operation: 'bulk', bulkChild: 'create' }, |
| 51 | + // Derived verbs the console's grid affordances read off the effective set. |
| 52 | + { consumer: 'console export', via: 'REST GET /data/sys_webhook/export', operation: 'export' }, |
| 53 | + { consumer: 'console import', via: 'REST POST /data/sys_webhook/import', operation: 'import' }, |
| 54 | +]; |
| 55 | + |
| 56 | +const ENABLE = SysWebhook.enable as EnableLike; |
| 57 | + |
| 58 | +describe('#9756 — sys_webhook declares its data-API exposure explicitly', () => { |
| 59 | + it('declares exactly the six primitives the census derived', () => { |
| 60 | + expect(ENABLE?.apiMethods).toEqual(['get', 'list', 'create', 'update', 'delete', 'bulk']); |
| 61 | + // Authored values are primitives only — legacy verbs are derived, never |
| 62 | + // declared (#3543). The monorepo-wide form of this lives in spec's |
| 63 | + // `api-methods-batch-conformance.test.ts`; asserted here too so the object's |
| 64 | + // own suite fails at the source rather than in another package. |
| 65 | + expect([...(ENABLE?.apiMethods ?? [])].sort()).toEqual([...API_PRIMITIVES].sort()); |
| 66 | + }); |
| 67 | + |
| 68 | + it('admits every consumer the census found (anti-vacuity floor included)', () => { |
| 69 | + expect(CENSUS.length).toBeGreaterThanOrEqual(10); |
| 70 | + const refused = CENSUS.filter( |
| 71 | + ({ operation, bulkChild }) => apiExposureDenialReason(ENABLE, operation, { bulkChild }) !== null, |
| 72 | + ).map(({ consumer, via, operation }) => `${consumer} (${via}) — '${operation}' refused`); |
| 73 | + expect(refused).toEqual([]); |
| 74 | + }); |
| 75 | + |
| 76 | + it('keeps every declared write verb through registration — nothing is stripped at boot', () => { |
| 77 | + // `sys_webhook` is `managedBy: 'config'`, so its whitelist is reconciled |
| 78 | + // against its resolved CRUD affordances at registration |
| 79 | + // (`reconcileManagedApiMethods`, objectql `registry.ts`) — a verb the |
| 80 | + // affordances refuse is stripped with only a `console.warn`. The judgement |
| 81 | + // is this predicate (ADR-0092/ADR-0103); the registry is only its reaction, |
| 82 | + // so pinning the predicate pins what boot will do. Closing |
| 83 | + // `userActions.delete`, say, would silently take `delete` away from the API |
| 84 | + // and this is what notices. |
| 85 | + expect(checkManagedApiMethodAffordances(SysWebhook)).toEqual([]); |
| 86 | + }); |
| 87 | + |
| 88 | + it('⛔ narrows NOTHING — the effective surface equals what the absent block produced', () => { |
| 89 | + // THE assertion of this file. `resolveEffectiveApiMethods` seeds its |
| 90 | + // `unrestricted` branch with the same `API_PRIMITIVES` set, so declaring |
| 91 | + // all six reproduces the closure the omission already had. If a later |
| 92 | + // change makes this pair diverge, the object's exposure really did move and |
| 93 | + // the docblock above (and #9756's report) stop describing it. |
| 94 | + const declared = resolveEffectiveApiMethods(ENABLE); |
| 95 | + const absent = resolveEffectiveApiMethods({ ...ENABLE, apiMethods: undefined }); |
| 96 | + |
| 97 | + expect(effectiveOperationsArray(declared)).toEqual(effectiveOperationsArray(absent)); |
| 98 | + expect([...declared.primitives].sort()).toEqual([...absent.primitives].sort()); |
| 99 | + // The one thing that DID change — and the only thing. |
| 100 | + expect(absent.mode).toBe('unrestricted'); |
| 101 | + expect(declared.mode).toBe('restricted'); |
| 102 | + }); |
| 103 | + |
| 104 | + it('leaves the reachable-cleartext fields reachable — the card is not closed by this', () => { |
| 105 | + // `url` (#8025, won't-fix on masking) and a legacy row's un-migrated |
| 106 | + // `definition_json.headers` (#7986, still read by `readLegacyHeaders`) are |
| 107 | + // served by `get`/`list`, which the console requires. Stated as an |
| 108 | + // assertion so nobody reads the new `enable` block as having removed them. |
| 109 | + expect(apiExposureDenialReason(ENABLE, 'get')).toBeNull(); |
| 110 | + expect(apiExposureDenialReason(ENABLE, 'list')).toBeNull(); |
| 111 | + expect(Object.keys(SysWebhook.fields)).toContain('url'); |
| 112 | + expect(Object.keys(SysWebhook.fields)).toContain('definition_json'); |
| 113 | + }); |
| 114 | +}); |
| 115 | + |
| 116 | +describe('#9756 — the gate this declaration is read by is live (counterfactual)', () => { |
| 117 | + // The shipped block refuses none of the census, so a refusal pin needs a |
| 118 | + // counterfactual subject: a narrowed block proves the mechanism reaching this |
| 119 | + // object's `enable` really does refuse, rather than the suite passing because |
| 120 | + // nothing is ever gated. ADR-0112: assert the discriminant AND the code, not |
| 121 | + // that something merely threw. |
| 122 | + const READ_ONLY: EnableLike = { apiMethods: ['get', 'list'] }; |
| 123 | + |
| 124 | + it('refuses a write with the ADR-0112 method-not-allowed discriminant', () => { |
| 125 | + expect(apiExposureDenialReason(READ_ONLY, 'create')).toBe('method-not-allowed'); |
| 126 | + expect(apiExposureDenialReason(READ_ONLY, 'update')).toBe('method-not-allowed'); |
| 127 | + expect(apiExposureDenialReason(READ_ONLY, 'delete')).toBe('method-not-allowed'); |
| 128 | + expect(apiExposureDenialReason(READ_ONLY, 'bulk', { bulkChild: 'update' })).toBe('method-not-allowed'); |
| 129 | + // Reads stay open — the control that makes the three above an oracle rather |
| 130 | + // than "this helper refuses everything". |
| 131 | + expect(apiExposureDenialReason(READ_ONLY, 'get')).toBeNull(); |
| 132 | + expect(apiExposureDenialReason(READ_ONLY, 'list')).toBeNull(); |
| 133 | + }); |
| 134 | + |
| 135 | + it('names an ADR-0112-registered code for each refusal envelope', () => { |
| 136 | + // The `{ status, code }` envelopes themselves are built by |
| 137 | + // `apiAccessDenialFromEnable` (`@objectstack/rest`) and the MCP bridge, from |
| 138 | + // this same discriminant — 405 `OBJECT_API_METHOD_NOT_ALLOWED` and 404 |
| 139 | + // `OBJECT_API_DISABLED`. This package does not depend on `@objectstack/rest` |
| 140 | + // and does not grow a dependency to assert someone else's envelope; what is |
| 141 | + // pinned here is that both codes are registered vocabulary, so a rename |
| 142 | + // cannot pass silently on the spec side. |
| 143 | + expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_METHOD_NOT_ALLOWED'); |
| 144 | + expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_DISABLED'); |
| 145 | + expect(apiExposureDenialReason({ apiEnabled: false }, 'get')).toBe('api-disabled'); |
| 146 | + }); |
| 147 | +}); |
0 commit comments