|
40 | 40 | * |
41 | 41 | * Refusal cases assert `code` AND `status` (ADR-0112): a status-only assertion |
42 | 42 | * stays green against an implementation that answers the wrong refusal. |
| 43 | + * |
| 44 | + * ## Two more published surfaces move with the verb, and are pinned here too |
| 45 | + * |
| 46 | + * The contract review of PR #16687 measured what the first round did not name: |
| 47 | + * |
| 48 | + * - the DERIVED `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec`, |
| 49 | + * `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so |
| 50 | + * granting `update` admits `POST /data/sys_organization/import` in |
| 51 | + * `writeMode: 'update'` — one request updates N rows. It is column-safe for |
| 52 | + * the same reason the PATCH is: the import runner writes each row under the |
| 53 | + * caller's context, so the D2 guard clamps every row (`timezone` lands, |
| 54 | + * `name` is stripped; a row carrying only better-auth columns is refused |
| 55 | + * per row; `treatAsHistorical` does not elevate). Insert / upsert modes |
| 56 | + * stay 405, and the conjunct the envelope names is `create`; |
| 57 | + * - `/auth/me/permissions`, the payload the console renders its edit |
| 58 | + * affordance from: `clampManagedObjectWrites` reads `userActions.edit` for |
| 59 | + * the `better-auth` bucket and `annotateEffectiveApiOperations` reports the |
| 60 | + * effective operation set, so for a principal the permission layer already |
| 61 | + * admits, `sys_organization.allowEdit` goes false → true and |
| 62 | + * `apiOperations` gains `update` and `import`. |
| 63 | + * |
| 64 | + * ⚠️ Instrument note for anything `reconcileManagedApiMethods` touches: it runs |
| 65 | + * at REGISTRATION, not at build, so a property-read of `dist/` cannot see it — |
| 66 | + * with `userActions` removed, `dist` still says `["get","list","update"]` while |
| 67 | + * the registered schema says `["get","list"]`. The registered-schema pin at the |
| 68 | + * bottom of this file, and the `/me/permissions` pin, are the instruments that |
| 69 | + * can. |
43 | 70 | */ |
44 | 71 |
|
45 | 72 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
@@ -191,6 +218,106 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy |
191 | 218 | expect(deletedBody.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); |
192 | 219 | }); |
193 | 220 |
|
| 221 | + it('the payload the console consumes: /auth/me/permissions says sys_organization is editable, with update and import in apiOperations', async () => { |
| 222 | + const res = await stack.apiAs(token, 'GET', '/auth/me/permissions'); |
| 223 | + expect(res.status).toBe(200); |
| 224 | + const body: any = await res.json(); |
| 225 | + const entry = body.objects?.sys_organization; |
| 226 | + expect(entry, 'sys_organization entry present in /me/permissions').toBeTruthy(); |
| 227 | + |
| 228 | + // `clampManagedObjectWrites` — the `better-auth` bucket is clamped to its |
| 229 | + // `userActions`; `edit` is the one opened, `create` / `delete` stay off. |
| 230 | + expect(entry.allowEdit).toBe(true); |
| 231 | + expect(entry.allowCreate).toBe(false); |
| 232 | + expect(entry.allowDelete).toBe(false); |
| 233 | + |
| 234 | + // `annotateEffectiveApiOperations` — the effective set the console renders: |
| 235 | + // the ruled verb and the door it derives, never the ones not granted. |
| 236 | + expect(entry.apiOperations).toContain('update'); |
| 237 | + expect(entry.apiOperations).toContain('import'); |
| 238 | + expect(entry.apiOperations).not.toContain('create'); |
| 239 | + expect(entry.apiOperations).not.toContain('delete'); |
| 240 | + expect(entry.apiOperations).not.toContain('bulk'); |
| 241 | + |
| 242 | + // Control: the clamp is live, not a wildcard fold reporting everything |
| 243 | + // editable — a sibling better-auth table with no `userActions` stays |
| 244 | + // `allowEdit: false` for the very same principal. |
| 245 | + const control = body.objects?.sys_member; |
| 246 | + expect(control, 'sys_member entry present (control)').toBeTruthy(); |
| 247 | + expect(control.allowEdit).toBe(false); |
| 248 | + expect(control.apiOperations ?? []).not.toContain('update'); |
| 249 | + }); |
| 250 | + |
| 251 | + it('the derived import door is open in update mode, and the guard clamps every row: timezone lands, name is stripped', async () => { |
| 252 | + const before = await readOrg(orgId); |
| 253 | + |
| 254 | + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { |
| 255 | + format: 'json', |
| 256 | + writeMode: 'update', |
| 257 | + matchFields: ['id'], |
| 258 | + rows: [{ id: orgId, name: 'Imported Name', timezone: 'Asia/Tokyo' }], |
| 259 | + }); |
| 260 | + expect(res.status).toBe(200); |
| 261 | + const body: any = await res.json(); |
| 262 | + expect(body.writeMode).toBe('update'); |
| 263 | + expect(body.updated).toBe(1); |
| 264 | + expect(body.errors).toBe(0); |
| 265 | + |
| 266 | + const after = await readOrg(orgId); |
| 267 | + // The door is open: the whitelisted column landed through import… |
| 268 | + expect(after.timezone).toBe('Asia/Tokyo'); |
| 269 | + // …and the row was written under the caller's context, not as system: |
| 270 | + // `name` did not land. Measured (ablation, contract-review patch round): |
| 271 | + // with `name` added to the guard's whitelist this assertion STAYS green, |
| 272 | + // because `name` is `readonly` (ADR-0092 D4) and the engine's |
| 273 | + // static-readonly strip — after the guard, non-system callers only — holds |
| 274 | + // it too. Two layers, one observable. The guard-SPECIFIC control on the |
| 275 | + // import path is the next pin (a better-auth-only row is refused per row): |
| 276 | + // under the same cut it goes red. What THIS assertion fails on is the |
| 277 | + // runner elevating rows to system context, which exempts both layers. |
| 278 | + expect(after.name).toBe(before.name); |
| 279 | + expect(after.name).not.toBe('Imported Name'); |
| 280 | + }); |
| 281 | + |
| 282 | + it('import: a row carrying only better-auth columns is refused per row, PERMISSION_DENIED — treatAsHistorical does not elevate', async () => { |
| 283 | + const before = await readOrg(orgId); |
| 284 | + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { |
| 285 | + format: 'json', |
| 286 | + writeMode: 'update', |
| 287 | + matchFields: ['id'], |
| 288 | + treatAsHistorical: true, |
| 289 | + rows: [{ id: orgId, name: 'Imported Name 2' }], |
| 290 | + }); |
| 291 | + // The import route's contract is a per-row outcome report: the request is |
| 292 | + // answered 200 and the refusal lives on the row. This is the guard's own |
| 293 | + // verdict on the import path (measured red the moment the whitelist admits |
| 294 | + // `name`), the same way the name-only PATCH pin above is on the PATCH path. |
| 295 | + expect(res.status).toBe(200); |
| 296 | + const body: any = await res.json(); |
| 297 | + expect(body.updated).toBe(0); |
| 298 | + expect(body.results?.[0]?.ok).toBe(false); |
| 299 | + expect(body.results?.[0]?.code).toBe('PERMISSION_DENIED'); |
| 300 | + expect((await readOrg(orgId)).name).toBe(before.name); |
| 301 | + }); |
| 302 | + |
| 303 | + it('import: insert mode is still refused at the method gate — 405, and the conjunct named is create', async () => { |
| 304 | + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { |
| 305 | + format: 'json', |
| 306 | + writeMode: 'insert', |
| 307 | + rows: [{ name: 'Forged Via Import', slug: 'forged-via-import' }], |
| 308 | + }); |
| 309 | + expect(res.status).toBe(405); |
| 310 | + const body: any = await res.json(); |
| 311 | + expect(body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); |
| 312 | + // `deniedConjunctName` names the primitive that actually failed: import |
| 313 | + // in insert mode needs `create`, which stays off. |
| 314 | + expect(String(body.error)).toContain("'create'"); |
| 315 | + // …and the same envelope advertises the derived door the ruling opened. |
| 316 | + expect(body.allowed).toContain('update'); |
| 317 | + expect(body.allowed).toContain('import'); |
| 318 | + expect(body.allowed).not.toContain('create'); |
| 319 | + }); |
| 320 | + |
194 | 321 | it('the REGISTERED schema serves `update` (post-reconcile) and better-auth keeps its own door', async () => { |
195 | 322 | // The original defect was a DECLARATION disagreeing with the runtime, so |
196 | 323 | // pin what the runtime actually serves. `reconcileManagedApiMethods` |
|
0 commit comments