|
66 | 66 | */ |
67 | 67 |
|
68 | 68 | import { describe, expect, it, vi } from 'vitest'; |
69 | | -import { ObjectQL } from '@objectstack/objectql'; |
| 69 | +// `assertEngineUpdateDispatch` re-exports the shared producer-side predicate |
| 70 | +// (`@objectstack/metadata-core` since #5619) through objectql, which is already |
| 71 | +// a devDependency here and already aliased to SOURCE by `vitest.config.ts` — so |
| 72 | +// the #8262 doubles below stay pinned to the real dispatch contract without |
| 73 | +// adding a dependency or an alias. |
| 74 | +import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql'; |
70 | 75 | import { SysSecret, SysSetting } from '@objectstack/platform-objects/system'; |
71 | 76 | import type { SettingsManifest } from '@objectstack/spec/system'; |
72 | 77 | import { SettingsService } from './settings-service.js'; |
73 | 78 | import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; |
74 | 79 | import { LocalCryptoProvider } from './local-crypto-provider.js'; |
75 | 80 | import { dropEchoedSecretMasks, SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; |
76 | | -import type { SettingsSecretStore } from './settings-service.types.js'; |
| 81 | +import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; |
77 | 82 |
|
78 | 83 | // --------------------------------------------------------------------------- |
79 | 84 | // Fixtures |
@@ -173,17 +178,87 @@ function makeMemoryDriver() { |
173 | 178 | return { driver, rowsOf }; |
174 | 179 | } |
175 | 180 |
|
| 181 | +/** |
| 182 | + * [#8262] Reproduces the adapter the `SettingsEngine` doc comment warns about: |
| 183 | + * identical to `wrapEngineAsSettingsEngine` except that it DROPS `context` on |
| 184 | + * the way to the engine. |
| 185 | + * |
| 186 | + * That single omission is the whole hazard — `sys_setting.value_enc` is |
| 187 | + * declared `readonly: true` and the engine strips author-declared read-only |
| 188 | + * columns from a NON-system caller's UPDATE (`stripReadonlyFields`, gated on |
| 189 | + * `context.isSystem`), while the INSERT path is exempt (#3413). It is a |
| 190 | + * documented extension point, so the population that reaches it is real: |
| 191 | + * third-party adapter authors, who have no other discovery path. |
| 192 | + */ |
| 193 | +function wrapEngineDroppingContext(engine: any): SettingsEngine { |
| 194 | + const real = wrapEngineAsSettingsEngine(engine); |
| 195 | + return { |
| 196 | + find: real.find.bind(real), |
| 197 | + insert: real.insert.bind(real), |
| 198 | + async update(objectName, opts) { |
| 199 | + // Loose in exactly ONE dimension — `context` — and conformant in every |
| 200 | + // other, or the row states it produces stop being evidence about the |
| 201 | + // real engine. `multi: true` is passed unconditionally because that IS |
| 202 | + // the settings adapter's contract (a scalar `where.id` outranks `multi` |
| 203 | + // in the shared predicate, and the settings row write never has one). |
| 204 | + assertEngineUpdateDispatch((opts as any)?.data ?? {}, { |
| 205 | + where: (opts as any)?.where, |
| 206 | + multi: true, |
| 207 | + }); |
| 208 | + const { context: _dropped, ...withoutContext } = opts as any; |
| 209 | + return real.update(objectName, withoutContext); |
| 210 | + }, |
| 211 | + }; |
| 212 | +} |
| 213 | + |
| 214 | +/** |
| 215 | + * [#8262] Forwards `context` correctly, but makes the FIRST read after any |
| 216 | + * update throw — which is exactly the reaper's post-write verification read. |
| 217 | + * |
| 218 | + * The constraint this exists to pin: `reapRotatedSecret` runs after the write |
| 219 | + * has committed and is "never allowed to fail the write" (its call site says |
| 220 | + * so). Adding a read to it must not turn a transient read failure into a |
| 221 | + * failed rotation. |
| 222 | + */ |
| 223 | +function wrapEngineFailingPostUpdateReads(engine: any): SettingsEngine { |
| 224 | + const real = wrapEngineAsSettingsEngine(engine); |
| 225 | + let armed = false; |
| 226 | + return { |
| 227 | + async find(objectName, opts) { |
| 228 | + if (armed) { |
| 229 | + armed = false; |
| 230 | + throw new Error('read replica offline'); |
| 231 | + } |
| 232 | + return real.find(objectName, opts); |
| 233 | + }, |
| 234 | + insert: real.insert.bind(real), |
| 235 | + async update(objectName, opts) { |
| 236 | + assertEngineUpdateDispatch((opts as any)?.data ?? {}, { |
| 237 | + where: (opts as any)?.where, |
| 238 | + multi: true, |
| 239 | + }); |
| 240 | + const res = await real.update(objectName, opts); |
| 241 | + armed = true; |
| 242 | + return res; |
| 243 | + }, |
| 244 | + }; |
| 245 | +} |
| 246 | + |
176 | 247 | /** |
177 | 248 | * The four pieces the running server bolts together: a real engine over the |
178 | 249 | * real system objects, the real `IDataEngine → SettingsEngine` adapter, the |
179 | 250 | * real `sys_secret` store the plugin builds, and the real service. |
180 | 251 | * |
181 | 252 | * `secretStoreOverrides` lets one case break `delete` without touching the |
182 | | - * others; `withDelete: false` reproduces a store that cannot reap at all. |
| 253 | + * others; `withDelete: false` reproduces a store that cannot reap at all; |
| 254 | + * `forwardContext: false` / `failVerificationRead` swap in the two #8262 |
| 255 | + * adapters above. |
183 | 256 | */ |
184 | 257 | async function boot(opts: { |
185 | 258 | secretStoreOverrides?: Partial<SettingsSecretStore>; |
186 | 259 | withDelete?: boolean; |
| 260 | + forwardContext?: boolean; |
| 261 | + failVerificationRead?: boolean; |
187 | 262 | } = {}) { |
188 | 263 | const engine = new ObjectQL(); |
189 | 264 | const { driver, rowsOf } = makeMemoryDriver(); |
@@ -218,7 +293,11 @@ async function boot(opts: { |
218 | 293 | const logged: string[] = []; |
219 | 294 | const svc = new SettingsService({ |
220 | 295 | env: {}, |
221 | | - engine: wrapEngineAsSettingsEngine(engine as any), |
| 296 | + engine: opts.failVerificationRead |
| 297 | + ? wrapEngineFailingPostUpdateReads(engine) |
| 298 | + : opts.forwardContext === false |
| 299 | + ? wrapEngineDroppingContext(engine) |
| 300 | + : wrapEngineAsSettingsEngine(engine as any), |
222 | 301 | cryptoProvider: new LocalCryptoProvider(), |
223 | 302 | secretStore: { ...baseStore, ...(opts.secretStoreOverrides ?? {}) }, |
224 | 303 | logger: { error: (m) => { logged.push(m); } }, |
@@ -460,3 +539,126 @@ describe('#8030 — wrapEngineAsSettingsEngine forwards the execution context', |
460 | 539 | expect(calls[0][2]).toMatchObject({ bypassTenantAudit: true, context: { isSystem: true } }); |
461 | 540 | }); |
462 | 541 | }); |
| 542 | + |
| 543 | +// --------------------------------------------------------------------------- |
| 544 | +// 8. #8262 — the reaper VERIFIES the repoint instead of inferring it |
| 545 | +// --------------------------------------------------------------------------- |
| 546 | + |
| 547 | +/** |
| 548 | + * #8262 — `reapRotatedSecret` deleted the handle `upsertRow` reported as |
| 549 | + * `previousEnc` without ever confirming the repoint it was cleaning up after |
| 550 | + * had taken effect; it inferred that from `previousEnc !== nextEnc`. |
| 551 | + * |
| 552 | + * That inference holds for the shipped adapter (which forwards |
| 553 | + * `context: { isSystem: true }`) and fails for one that drops it — the reader |
| 554 | + * `SettingsEngine`'s own doc comment contemplates. With `context` dropped the |
| 555 | + * UPDATE has `value_enc` stripped, so the row still names `previousEnc`, and |
| 556 | + * the reaper deleted **the ciphertext still in force**: `materialiseRow` |
| 557 | + * dereferences a dangling handle, gets nothing, and the setting silently reads |
| 558 | + * empty. Unrecoverable — the audit trail records digests, never handles. |
| 559 | + * |
| 560 | + * Before the reaper existed the same adapter bug was non-destructive (the |
| 561 | + * rotated-away credential merely stayed in force). These cases pin that the |
| 562 | + * failure mode is back to recoverable, and now LOUD rather than silent. |
| 563 | + * |
| 564 | + * ⚠️ Direction of the counterfactual: on the pre-fix source, `three writes` |
| 565 | + * below reads `[1, 1, 2]` and the value reads back `null`. The fix does not |
| 566 | + * make a context-dropping adapter correct — nothing at this layer can, the |
| 567 | + * repoint is stripped one layer down — it makes the failure survivable. |
| 568 | + * Sections 5 and 6 are the other half of the pin: with `context` forwarded, |
| 569 | + * the verification passes and reaping still happens on every rotation, so a |
| 570 | + * fix that simply stopped reaping would go red there. |
| 571 | + */ |
| 572 | +describe('#8262 — the reaper never deletes the ciphertext that is still in force', () => { |
| 573 | + it('a context-dropping adapter keeps the in-force ciphertext, and the setting still reads', async () => { |
| 574 | + const { svc, settingRow, secretRows } = await boot({ forwardContext: false }); |
| 575 | + |
| 576 | + await svc.set('sms', 'twilio_auth_token', 'alpha'); |
| 577 | + const handleA = settingRow()?.value_enc as string; |
| 578 | + expect(handleA).toMatch(/^sec_/); |
| 579 | + expect(secretRows()).toHaveLength(1); |
| 580 | + |
| 581 | + // The second write is where the defect lived: the repoint is stripped, so |
| 582 | + // `value_enc` still names `handleA`, while `upsertRow` reports it as the |
| 583 | + // handle rotated AWAY from. |
| 584 | + await svc.set('sms', 'twilio_auth_token', 'beta'); |
| 585 | + |
| 586 | + // The strip itself is NOT this card's subject and is unchanged: the row |
| 587 | + // still names the old handle. What must never happen is the deletion. |
| 588 | + expect(settingRow()?.value_enc).toBe(handleA); |
| 589 | + |
| 590 | + // ⛔ THE assertion. Pre-fix this row was gone and `value_enc` dangled. |
| 591 | + expect(secretRows().some((r) => r.id === handleA)).toBe(true); |
| 592 | + |
| 593 | + // …and the consequence that makes it data loss rather than a stale value: |
| 594 | + // pre-fix this read returned `null` with the credential unrecoverable. |
| 595 | + expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('alpha'); |
| 596 | + }); |
| 597 | + |
| 598 | + it('three writes leave the recoverable pre-reaper shape (1→2→3), not the destructive one (1→1→2)', async () => { |
| 599 | + const { svc, settingRow, secretRows } = await boot({ forwardContext: false }); |
| 600 | + |
| 601 | + await svc.set('sms', 'twilio_auth_token', 'tok-1'); |
| 602 | + const pinned = settingRow()?.value_enc as string; |
| 603 | + const afterFirst = secretRows().length; |
| 604 | + |
| 605 | + await svc.set('sms', 'twilio_auth_token', 'tok-2'); |
| 606 | + const afterSecond = secretRows().length; |
| 607 | + |
| 608 | + await svc.set('sms', 'twilio_auth_token', 'tok-3'); |
| 609 | + const afterThird = secretRows().length; |
| 610 | + |
| 611 | + // The card's table, inverted. `[1, 1, 2]` is the destructive shape: the |
| 612 | + // second write deleted the row the setting pointed at. |
| 613 | + expect([afterFirst, afterSecond, afterThird]).toEqual([1, 2, 3]); |
| 614 | + expect(settingRow()?.value_enc).toBe(pinned); |
| 615 | + expect(secretRows().some((r) => r.id === pinned)).toBe(true); |
| 616 | + expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('tok-1'); |
| 617 | + }); |
| 618 | + |
| 619 | + it('says so LOUDLY — the failure the adapter doc calls silent now names itself', async () => { |
| 620 | + const { svc, logged } = await boot({ forwardContext: false }); |
| 621 | + await svc.set('sms', 'twilio_auth_token', 'alpha'); |
| 622 | + await svc.set('sms', 'twilio_auth_token', 'beta'); |
| 623 | + |
| 624 | + const out = logged.join('\n'); |
| 625 | + // The operator's question is "did my rotation happen?", so the message has |
| 626 | + // to answer that, name the row, and name the cause. |
| 627 | + expect(out).toMatch(/did NOT take effect/); |
| 628 | + expect(out).toMatch(/sms\.twilio_auth_token/); |
| 629 | + expect(out).toMatch(/context/); |
| 630 | + // It must be a refusal, not a report of something already destroyed. |
| 631 | + expect(out).toMatch(/REFUSED to delete/); |
| 632 | + }); |
| 633 | + |
| 634 | + it('a verification read that THROWS still leaves the rotation landed (never fails the write)', async () => { |
| 635 | + const { svc, settingRow, secretRows, logged } = await boot({ failVerificationRead: true }); |
| 636 | + |
| 637 | + await svc.set('sms', 'twilio_auth_token', 'alpha'); |
| 638 | + const handleA = settingRow()?.value_enc as string; |
| 639 | + |
| 640 | + // The write itself must survive a reaper that cannot verify — the call |
| 641 | + // site's "never allowed to fail the write" constraint covers the read the |
| 642 | + // fix added, not just the delete. |
| 643 | + await expect(svc.set('sms', 'twilio_auth_token', 'beta')).resolves.toBeDefined(); |
| 644 | + |
| 645 | + // The rotation landed (context IS forwarded here) … |
| 646 | + const handleB = settingRow()?.value_enc as string; |
| 647 | + expect(handleB).not.toBe(handleA); |
| 648 | + expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('beta'); |
| 649 | + |
| 650 | + // … and the unverifiable handle was left alone rather than destroyed on a |
| 651 | + // guess. An orphan is recoverable; a deleted in-force ciphertext is not. |
| 652 | + expect(secretRows().some((r) => r.id === handleA)).toBe(true); |
| 653 | + expect(logged.join('\n')).toMatch(/could not confirm/); |
| 654 | + }); |
| 655 | + |
| 656 | + it('a store with no delete is unaffected — no verification read is issued at all', async () => { |
| 657 | + // The verification read costs one I/O and must only be paid where a |
| 658 | + // destructive delete would otherwise follow. |
| 659 | + const { svc, secretRows } = await boot({ withDelete: false, forwardContext: false }); |
| 660 | + await svc.set('sms', 'twilio_auth_token', 'alpha'); |
| 661 | + await svc.set('sms', 'twilio_auth_token', 'beta'); |
| 662 | + expect(secretRows()).toHaveLength(2); |
| 663 | + }); |
| 664 | +}); |
0 commit comments