@@ -41,7 +41,11 @@ import { AutoEnqueuer } from './auto-enqueuer.js';
4141import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js' ;
4242import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js' ;
4343import { SysWebhook } from './sys-webhook.object.js' ;
44- import { WEBHOOK_SECRET_FIELD , __objectqlSecretWireForms } from './webhook-secret.js' ;
44+ import {
45+ WEBHOOK_SECRET_FIELD ,
46+ __objectqlSecretWireForms ,
47+ resolveWebhookSecret ,
48+ } from './webhook-secret.js' ;
4549import { WEBHOOK_HEADERS_FIELD } from './webhook-headers.js' ;
4650
4751/**
@@ -951,3 +955,175 @@ describe('fail-closed and re-arm, extended to headers (#7986 × #7799/#8022)', (
951955 expect ( calls [ 0 ] . headers [ 'X-Objectstack-Signature' ] ) . toBe ( `sha256=${ expected } ` ) ;
952956 } ) ;
953957} ) ;
958+
959+ // ---------------------------------------------------------------------------
960+ // #8542 — the same invariant, failing in the OPPOSITE direction.
961+ //
962+ // Every fail-closed pin above is reached by making the resolver THROW. There is
963+ // a second way for a stored key not to come back, and it used to be silent:
964+ // `resolveSecretField` answers `null`, `resolveWebhookSecret` folded that onto
965+ // the `undefined` it uses for "authored unsigned", and `attachSecret` acted on
966+ // that reading — the subscription ARMED and every delivery went out
967+ // unauthenticated while `sys_webhook` kept reading `active: true`. Nothing
968+ // logged, nothing dropped, no `sys_http_delivery` row to find. The receiver's
969+ // only proof of origin simply stopped being attached.
970+ //
971+ // That is why these pins are written on the WIRE and on the durable record
972+ // rather than on the resolver's return value: the defect's entire signature is
973+ // a request that arrives looking normal.
974+ //
975+ // ⚠️ Every pin here first asserts that a secret is genuinely STORED (the row
976+ // reads back as the mask, and the operator sees `active: true`). Without that
977+ // precondition a fixture that had quietly lost its secret would exercise only
978+ // the legitimate-unsigned arm and pass against a completely unfixed tree — the
979+ // control at the end of this block is that arm, deliberately kept separate.
980+ // ---------------------------------------------------------------------------
981+
982+ describe ( 'a stored signing secret that resolves to nothing (#8542)' , ( ) => {
983+ /** Drive one create event through the PRODUCTION wiring; report all three surfaces. */
984+ async function driveOnce ( engine : any ) {
985+ const realtime = new FakeRealtime ( ) ;
986+ const outbox = new MemoryHttpOutbox ( ) ;
987+ const errors : Array < { msg : string ; meta : any } > = [ ] ;
988+ const enqueuer = new AutoEnqueuer ( engine , realtime , enqueueVia ( outbox ) , {
989+ // The 60s escape hatch held shut, as in the #8022 pins: whatever the
990+ // first cache build concluded is what these assertions see.
991+ refreshIntervalMs : 0 ,
992+ logger : {
993+ error : ( msg : string , _e ?: unknown , meta ?: unknown ) => { errors . push ( { msg, meta : meta as any } ) ; } ,
994+ debug : ( ) => { } , warn : ( ) => { } ,
995+ } ,
996+ } ) ;
997+ await enqueuer . start ( ) ;
998+ await realtime . publish ( recordEvent ( 'contact' , { id : 'c1' , name : 'Ada' } ) ) ;
999+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1000+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1001+ const { impl, calls } = makeFetch ( ) ;
1002+ await new HttpDispatcher ( { nodeId : 'n1' , outbox, fetchImpl : impl , partitionCount : 1 } ) . tick ( ) ;
1003+ await enqueuer . stop ( ) ;
1004+ return { calls, rows : await outbox . list ( ) , errors } ;
1005+ }
1006+
1007+ /**
1008+ * The anti-vacuity precondition. Asserts the state the whole card is about:
1009+ * a secret IS stored, everything an operator can read says so, and the
1010+ * webhook is armed. A test that reached the assertions below WITHOUT this
1011+ * state would be testing the legitimate unsigned arm and would pass on an
1012+ * unfixed tree.
1013+ */
1014+ async function expectSecretGenuinelyStored ( engine : any , stores : any ) {
1015+ const [ viaApi ] = await engine . find ( 'sys_webhook' , { where : { name : 'crm_hook' } } ) ;
1016+ expect ( viaApi [ WEBHOOK_SECRET_FIELD ] ) . toBe ( SECRET_MASK ) ;
1017+ expect ( viaApi . active ) . toBe ( true ) ;
1018+ const atRest = Array . from ( stores . get ( 'sys_webhook' ) ! . values ( ) ) [ 0 ] as any ;
1019+ expect ( atRest [ WEBHOOK_SECRET_FIELD ] ) . toBeTruthy ( ) ;
1020+ return viaApi ;
1021+ }
1022+
1023+ /** What a fail-closed outcome has to look like on all three surfaces. */
1024+ function expectParkedNotDelivered ( result : Awaited < ReturnType < typeof driveOnce > > ) {
1025+ // ── The invariant, on the wire: nothing arrives, and in particular
1026+ // nothing arrives UNSIGNED. Asserting the absence of the signature
1027+ // header alone would pass on a tree that delivers, which is the defect.
1028+ expect ( result . calls ) . toHaveLength ( 0 ) ;
1029+ // ── #8069: the discarded event still leaves a durable trace.
1030+ expect ( result . rows ) . toHaveLength ( 1 ) ;
1031+ expect ( result . rows [ 0 ] ) . toMatchObject ( { status : 'dead' , attempts : 0 } ) ;
1032+ expect ( result . rows [ 0 ] . signature ) . toBeUndefined ( ) ;
1033+ // ── #8022/#8043: one say-once `error`, carrying the ADR-0112 pair a
1034+ // consumer branches on, and naming the credential so this cannot be
1035+ // confused with the header map's identical-looking drop (#7986).
1036+ expect ( result . errors ) . toHaveLength ( 1 ) ;
1037+ expect ( result . errors [ 0 ] . meta ) . toMatchObject ( {
1038+ code : 'INTERNAL_ERROR' ,
1039+ status : 500 ,
1040+ field : WEBHOOK_SECRET_FIELD ,
1041+ } ) ;
1042+ }
1043+
1044+ it ( 'refuses to arm when the stored secret was emptied through the ordinary data API' , async ( ) => {
1045+ const { engine, stores } = await buildEngine ( ) ;
1046+ await bootstrapDeclaredWebhooks ( engine , metadataWith ( [ declaredWebhook ( ) ] ) ) ;
1047+ const [ row ] = await engine . find ( 'sys_webhook' , { where : { name : 'crm_hook' } } ) ;
1048+
1049+ // The trigger that needs no privileged access at all — measured, not
1050+ // assumed. The engine accepts an empty string for a `secret` field,
1051+ // encrypts it like any other, mints a real `sys_secret` row, and leaves
1052+ // the column holding a perfectly VALID ref. Every read path then
1053+ // reports a secret is set, and the dereference answers ''.
1054+ await engine . update ( 'sys_webhook' , { [ WEBHOOK_SECRET_FIELD ] : '' } , { where : { id : row . id } } ) ;
1055+
1056+ await expectSecretGenuinelyStored ( engine , stores ) ;
1057+ const result = await driveOnce ( engine ) ;
1058+ expectParkedNotDelivered ( result ) ;
1059+ // The remedy this state specifically needs, since "re-save the secret"
1060+ // is not the only fix and an operator who wanted it unsigned has to be
1061+ // told the difference between an empty secret and no secret.
1062+ expect ( result . rows [ 0 ] . error ) . toMatch ( / c o u l d n o t b e d e c r y p t e d / ) ;
1063+ expect ( result . errors [ 0 ] . msg ) . toMatch ( / N O d e l i v e r y / ) ;
1064+ } ) ;
1065+
1066+ it ( 'refuses to arm when the column no longer holds a resolvable ref' , async ( ) => {
1067+ const { engine, stores, driver } = await buildEngine ( ) ;
1068+ await bootstrapDeclaredWebhooks ( engine , metadataWith ( [ declaredWebhook ( ) ] ) ) ;
1069+ const [ row ] = await engine . find ( 'sys_webhook' , { where : { name : 'crm_hook' } } ) ;
1070+
1071+ // Written BELOW the engine deliberately, because that is the only route
1072+ // measured to reach this state: the engine's own write path DROPS an
1073+ // echoed mask and RE-ENCRYPTS cleartext, so neither lands. What does
1074+ // land here is a dump restored without its sys_secret rows, a column
1075+ // edited in SQL, or a seed script writing at driver level.
1076+ await driver . update ( 'sys_webhook' , row . id , { [ WEBHOOK_SECRET_FIELD ] : 'whsec_pasted_by_hand' } ) ;
1077+
1078+ // …and the row still reads back as the mask, so nothing an operator can
1079+ // see distinguishes this from the healthy webhook it was a moment ago.
1080+ await expectSecretGenuinelyStored ( engine , stores ) ;
1081+ expectParkedNotDelivered ( await driveOnce ( engine ) ) ;
1082+ } ) ;
1083+
1084+ it ( 'refuses when the row is deleted between the cache read and the dereference' , async ( ) => {
1085+ const { engine } = await buildEngine ( ) ;
1086+ await bootstrapDeclaredWebhooks ( engine , metadataWith ( [ declaredWebhook ( ) ] ) ) ;
1087+
1088+ // The snapshot the enqueuer's refresh loop holds while it dereferences
1089+ // each row's credentials, one at a time.
1090+ const [ snapshot ] = await engine . find ( 'sys_webhook' , { where : { name : 'crm_hook' } } ) ;
1091+ expect ( snapshot [ WEBHOOK_SECRET_FIELD ] ) . toBe ( SECRET_MASK ) ;
1092+
1093+ await engine . delete ( 'sys_webhook' , { where : { id : snapshot . id } } ) ;
1094+
1095+ // Pinned at the seam rather than through the enqueuer because the race
1096+ // is a property of the seam: `resolveSecretField` opens `if (!row)
1097+ // return null`, and the caller holding the snapshot cannot tell that
1098+ // `null` apart from "this webhook was authored unsigned".
1099+ await expect ( resolveWebhookSecret ( engine , snapshot as any ) ) . rejects . toThrow (
1100+ / r e s o l v e d t o n o t h i n g / ,
1101+ ) ;
1102+ } ) ;
1103+
1104+ it ( 'a webhook authored unsigned still arms and delivers — the refusal is not a blanket' , async ( ) => {
1105+ const { engine } = await buildEngine ( ) ;
1106+ await bootstrapDeclaredWebhooks (
1107+ engine ,
1108+ metadataWith ( [ declaredWebhook ( { secret : undefined } ) ] ) ,
1109+ ) ;
1110+
1111+ // The control's precondition is the exact mirror of the anti-vacuity
1112+ // one above: NOTHING is stored, so `undefined` is the legitimate
1113+ // authored fact and not a swallowed failure. Its header map is left
1114+ // declared on purpose — the signing refusal must not spill onto the
1115+ // sibling credential that resolves perfectly well (#7986).
1116+ const [ viaApi ] = await engine . find ( 'sys_webhook' , { where : { name : 'crm_hook' } } ) ;
1117+ expect ( viaApi [ WEBHOOK_SECRET_FIELD ] ?? null ) . toBeNull ( ) ;
1118+
1119+ const { calls, rows, errors } = await driveOnce ( engine ) ;
1120+ expect ( calls ) . toHaveLength ( 1 ) ;
1121+ expect ( calls [ 0 ] . headers [ 'X-Objectstack-Signature' ] ) . toBeUndefined ( ) ;
1122+ expect ( calls [ 0 ] . headers [ 'X-Team' ] ) . toBe ( 'crm' ) ;
1123+ // `success`, not `pending`: the dispatcher ran, so this control asserts
1124+ // a delivery that COMPLETED — the working feature a blanket refusal
1125+ // would have turned into a parked `dead` row.
1126+ expect ( rows [ 0 ] . status ) . toBe ( 'success' ) ;
1127+ expect ( errors ) . toHaveLength ( 0 ) ;
1128+ } ) ;
1129+ } ) ;
0 commit comments