@@ -83,6 +83,7 @@ mock.module("@slack/bolt", () => ({
8383
8484// Import the channel AFTER the module mock so the constructor uses our doubles.
8585const { SlackHttpChannel } = await import ( "../slack-http-receiver.ts" ) ;
86+ type IntroductionLedger = import ( "../slack-http-receiver.ts" ) . IntroductionLedger ;
8687
8788const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ;
8889const TEAM_ID = "T9TK3CUKW" ;
@@ -778,6 +779,142 @@ describe("synthetic first DM on connect", () => {
778779 } ) ;
779780} ) ;
780781
782+ // ----- introductionLedger (persistent intro-DM idempotency) ----------------
783+ //
784+ // H3 root-cause fix: the in-memory `firstDmSent` flag lost its state on
785+ // every process restart, so a tenant whose first intro DM failed (or
786+ // silently never fired) had no record of the attempt. Phantom Cloud
787+ // production also lacked any signal in `/health` that the intro had
788+ // happened: `onboarding` stayed at "pending" because `markOnboardingStarted`
789+ // only ran from the Socket Mode `startOnboarding` path which is gated on a
790+ // `channels.yaml` that is never baked in HTTP-mode rootfs.
791+ //
792+ // The ledger surface fixes both legs in one place. Production wires it
793+ // against the SQLite `onboarding_state` table; these tests use a tiny
794+ // in-memory recorder that pins the contract:
795+ // 1. isIntroSent() === true short-circuits before any Slack call.
796+ // 2. markIntroSent() fires only AFTER a successful sendIntroductionDm.
797+ // 3. A throwing markIntroSent does not derail connect().
798+
799+ describe ( "introductionLedger persistent idempotency" , ( ) => {
800+ type LedgerStub = {
801+ state : { sent : boolean } ;
802+ isCalled : { isIntroSent : number ; markIntroSent : number } ;
803+ isIntroSent : ( ) => boolean ;
804+ markIntroSent : ( ) => void ;
805+ } ;
806+ function makeLedger ( initial : boolean ) : LedgerStub {
807+ const state = { sent : initial } ;
808+ const isCalled = { isIntroSent : 0 , markIntroSent : 0 } ;
809+ return {
810+ state,
811+ isCalled,
812+ isIntroSent : ( ) => {
813+ isCalled . isIntroSent ++ ;
814+ return state . sent ;
815+ } ,
816+ markIntroSent : ( ) => {
817+ isCalled . markIntroSent ++ ;
818+ state . sent = true ;
819+ } ,
820+ } ;
821+ }
822+
823+ test ( "skips intro DM when ledger already records intro_sent (process restart case)" , async ( ) => {
824+ const ledger = makeLedger ( true ) ;
825+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
826+ await channel . connect ( ) ;
827+ const calls = mockPostMessage . mock . calls as unknown as Array < [ { channel ?: string } ] > ;
828+ const introCalls = calls . filter ( ( c ) => c [ 0 ] . channel === "D_DM_OPEN" ) . length ;
829+ expect ( introCalls ) . toBe ( 0 ) ;
830+ expect ( ledger . isCalled . isIntroSent ) . toBeGreaterThanOrEqual ( 1 ) ;
831+ expect ( ledger . isCalled . markIntroSent ) . toBe ( 0 ) ;
832+ } ) ;
833+
834+ test ( "fires intro DM and stamps ledger on a successful send (first boot)" , async ( ) => {
835+ const ledger = makeLedger ( false ) ;
836+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
837+ await channel . connect ( ) ;
838+ const calls = mockPostMessage . mock . calls as unknown as Array < [ { channel ?: string } ] > ;
839+ const introCalls = calls . filter ( ( c ) => c [ 0 ] . channel === "D_DM_OPEN" ) . length ;
840+ expect ( introCalls ) . toBe ( 1 ) ;
841+ expect ( ledger . isCalled . markIntroSent ) . toBe ( 1 ) ;
842+ expect ( ledger . state . sent ) . toBe ( true ) ;
843+ } ) ;
844+
845+ test ( "does NOT stamp ledger when sendIntroductionDm fails (transient Slack error preserves retry budget)" , async ( ) => {
846+ // Slack rate-limit on chat.postMessage. The DM never reaches the
847+ // user; the ledger must stay clear so a process restart retries.
848+ mockPostMessage . mockImplementation ( ( ) => Promise . reject ( new Error ( "ratelimited" ) ) ) ;
849+ const ledger = makeLedger ( false ) ;
850+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
851+ await channel . connect ( ) ;
852+ expect ( channel . getConnectionState ( ) ) . toBe ( "connected" ) ;
853+ expect ( ledger . state . sent ) . toBe ( false ) ;
854+ expect ( ledger . isCalled . markIntroSent ) . toBe ( 0 ) ;
855+ mockPostMessage . mockImplementation ( ( ) => Promise . resolve ( { ts : "1234567890.123456" } ) ) ;
856+ } ) ;
857+
858+ test ( "does NOT stamp ledger when chat.postMessage returns no ts (ledger preserves retry across restart)" , async ( ) => {
859+ mockPostMessage . mockImplementationOnce ( ( ) => Promise . resolve ( { ts : "" } as { ts : string } ) ) ;
860+ const ledger = makeLedger ( false ) ;
861+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
862+ await channel . connect ( ) ;
863+ expect ( ledger . state . sent ) . toBe ( false ) ;
864+ expect ( ledger . isCalled . markIntroSent ) . toBe ( 0 ) ;
865+ } ) ;
866+
867+ test ( "ledger stamp survives reconnect: a subsequent connect() does not re-DM" , async ( ) => {
868+ const ledger = makeLedger ( false ) ;
869+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
870+ await channel . connect ( ) ;
871+ await channel . disconnect ( ) ;
872+ mockPostMessage . mockClear ( ) ;
873+ await channel . connect ( ) ;
874+ const calls = mockPostMessage . mock . calls as unknown as Array < [ { channel ?: string } ] > ;
875+ const introCalls = calls . filter ( ( c ) => c [ 0 ] . channel === "D_DM_OPEN" ) . length ;
876+ expect ( introCalls ) . toBe ( 0 ) ;
877+ } ) ;
878+
879+ test ( "a throwing markIntroSent does not derail connect() (defense in depth)" , async ( ) => {
880+ const ledger : IntroductionLedger = {
881+ isIntroSent : ( ) => false ,
882+ markIntroSent : ( ) => {
883+ throw new Error ( "disk full" ) ;
884+ } ,
885+ } ;
886+ const warns : string [ ] = [ ] ;
887+ const original = console . warn ;
888+ console . warn = ( ...args : unknown [ ] ) => {
889+ warns . push ( args . map ( String ) . join ( " " ) ) ;
890+ } ;
891+ try {
892+ const channel = new SlackHttpChannel ( { ...baseConfig , introductionLedger : ledger } ) ;
893+ await expect ( channel . connect ( ) ) . resolves . toBeUndefined ( ) ;
894+ expect ( channel . getConnectionState ( ) ) . toBe ( "connected" ) ;
895+ } finally {
896+ console . warn = original ;
897+ }
898+ const all = warns . join ( "\n" ) ;
899+ expect ( all ) . toContain ( "markIntroSent failed" ) ;
900+ expect ( all ) . toContain ( "disk full" ) ;
901+ } ) ;
902+
903+ test ( "without a ledger (single-tenant dev) the in-memory firstDmSent fallback is used" , async ( ) => {
904+ // No introductionLedger on baseConfig -> reconnect should still
905+ // short-circuit via the in-memory flag. This pins the back-compat
906+ // surface for self-hosted Socket Mode and unit-test fixtures.
907+ const channel = new SlackHttpChannel ( baseConfig ) ;
908+ await channel . connect ( ) ;
909+ await channel . disconnect ( ) ;
910+ mockPostMessage . mockClear ( ) ;
911+ await channel . connect ( ) ;
912+ const calls = mockPostMessage . mock . calls as unknown as Array < [ { channel ?: string } ] > ;
913+ const introCalls = calls . filter ( ( c ) => c [ 0 ] . channel === "D_DM_OPEN" ) . length ;
914+ expect ( introCalls ) . toBe ( 0 ) ;
915+ } ) ;
916+ } ) ;
917+
781918// ----- send and outbound API ----------------------------------------------
782919
783920describe ( "send / outbound" , ( ) => {
0 commit comments