@@ -3901,6 +3901,227 @@ describe("orchestrator runControlPlane in-process HTTP probes", () => {
39013901 // After stop() the watcher's unsubscribe ran → no reload fires anymore.
39023902 expect ( unwatched ) . toBe ( true ) ;
39033903 } ) ;
3904+
3905+ // ── /api/probes trigger endpoint on the control-plane ──────────────────
3906+ //
3907+ // The control-plane runs the 8 in-process HTTP probe families on its own
3908+ // scheduler under `probe:<id>` entries. The on-demand trigger endpoint
3909+ // (`POST /api/probes/:id/trigger`, bearer-auth gated) lets operators fire a
3910+ // family immediately instead of waiting on the slow cron. It is mounted via
3911+ // the SAME `registerProbesRoutes` boot() uses, wired from the control-plane's
3912+ // own `httpProbeRegistry`/`httpProbeConfigs`/`scheduler`/`httpRunWriter` and
3913+ // `OPS_TRIGGER_TOKEN`. The router id namespace is the prefixed scheduler id
3914+ // (`probe:<cfg.id>`), matching boot() and the in-process registration.
3915+ describe ( "on-demand trigger endpoint (/api/probes)" , ( ) => {
3916+ const TOKEN = "cp-trigger-token" ;
3917+ let prevToken : string | undefined ;
3918+
3919+ beforeEach ( ( ) => {
3920+ prevToken = process . env . OPS_TRIGGER_TOKEN ;
3921+ process . env . OPS_TRIGGER_TOKEN = TOKEN ;
3922+ } ) ;
3923+
3924+ afterEach ( ( ) => {
3925+ if ( prevToken === undefined ) delete process . env . OPS_TRIGGER_TOKEN ;
3926+ else process . env . OPS_TRIGGER_TOKEN = prevToken ;
3927+ } ) ;
3928+
3929+ function pbMock ( ) {
3930+ vi . doMock ( "@hono/node-server" , async ( ) => {
3931+ const actual =
3932+ await vi . importActual < typeof import ( "@hono/node-server" ) > (
3933+ "@hono/node-server" ,
3934+ ) ;
3935+ return { ...actual } ;
3936+ } ) ;
3937+ vi . doMock ( "./storage/pb-client.js" , async ( ) => {
3938+ const actual = await vi . importActual <
3939+ typeof import ( "./storage/pb-client.js" )
3940+ > ( "./storage/pb-client.js" ) ;
3941+ return {
3942+ ...actual ,
3943+ createPbClient : ( ) => ( {
3944+ health : async ( ) => true ,
3945+ getOne : async ( ) => null ,
3946+ getFirst : async ( ) => null ,
3947+ getList : async ( ) => ( { items : [ ] } ) ,
3948+ } ) ,
3949+ } ;
3950+ } ) ;
3951+ }
3952+
3953+ it ( "GET /api/probes lists the in-process HTTP families (and not the browser kind)" , async ( ) => {
3954+ vi . resetModules ( ) ;
3955+ pbMock ( ) ;
3956+ const orchMod = await import ( "./orchestrator.js" ) ;
3957+ const handle = await orchMod . runControlPlane (
3958+ { role : "control-plane" , poolCount : 1 } ,
3959+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
3960+ ) ;
3961+ try {
3962+ const res = await fetch ( `http://127.0.0.1:${ port } /api/probes` ) ;
3963+ expect ( res . status ) . toBe ( 200 ) ;
3964+ const body = ( await res . json ( ) ) as { probes : Array < { id : string } > } ;
3965+ const ids = body . probes . map ( ( p ) => p . id ) . sort ( ) ;
3966+ // The 3 HTTP families written to the temp probes dir, under their
3967+ // prefixed scheduler ids.
3968+ expect ( ids ) . toEqual (
3969+ [ "probe:image_drift" , "probe:qa" , "probe:smoke" ] . sort ( ) ,
3970+ ) ;
3971+ // Browser kind is worker-routed, never in-process → not listed.
3972+ expect ( ids ) . not . toContain ( "probe:e2e_smoke" ) ;
3973+ } finally {
3974+ await handle . stop ( ) ;
3975+ }
3976+ } ) ;
3977+
3978+ it ( "POST /api/probes/probe:smoke/trigger with the bearer token runs the in-process probe" , async ( ) => {
3979+ vi . resetModules ( ) ;
3980+ pbMock ( ) ;
3981+
3982+ // Capture trigger() calls on the real scheduler so we can assert the
3983+ // route routed the request to the control-plane's scheduler entry.
3984+ const triggered : string [ ] = [ ] ;
3985+ vi . doMock ( "./scheduler/scheduler.js" , async ( ) => {
3986+ const actual = await vi . importActual <
3987+ typeof import ( "./scheduler/scheduler.js" )
3988+ > ( "./scheduler/scheduler.js" ) ;
3989+ return {
3990+ ...actual ,
3991+ createScheduler : (
3992+ deps : Parameters < typeof actual . createScheduler > [ 0 ] ,
3993+ ) => {
3994+ const real = actual . createScheduler ( deps ) ;
3995+ return {
3996+ ...real ,
3997+ trigger : async ( id : string , opts ?: unknown ) => {
3998+ triggered . push ( id ) ;
3999+ return { runId : "run_cp" , status : "queued" , probe : id } ;
4000+ } ,
4001+ } ;
4002+ } ,
4003+ } ;
4004+ } ) ;
4005+
4006+ const orchMod = await import ( "./orchestrator.js" ) ;
4007+ const handle = await orchMod . runControlPlane (
4008+ { role : "control-plane" , poolCount : 1 } ,
4009+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4010+ ) ;
4011+ try {
4012+ const res = await fetch (
4013+ `http://127.0.0.1:${ port } /api/probes/probe:smoke/trigger` ,
4014+ {
4015+ method : "POST" ,
4016+ headers : { Authorization : `Bearer ${ TOKEN } ` } ,
4017+ } ,
4018+ ) ;
4019+ expect ( res . status ) . toBe ( 200 ) ;
4020+ const body = ( await res . json ( ) ) as { runId : string ; probe : string } ;
4021+ expect ( body . runId ) . toBe ( "run_cp" ) ;
4022+ expect ( body . probe ) . toBe ( "probe:smoke" ) ;
4023+ // The route invoked the control-plane scheduler's entry for the probe.
4024+ expect ( triggered ) . toContain ( "probe:smoke" ) ;
4025+ } finally {
4026+ await handle . stop ( ) ;
4027+ }
4028+ } ) ;
4029+
4030+ it ( "POST /api/probes/probe:smoke/trigger WITHOUT the bearer token 401s" , async ( ) => {
4031+ vi . resetModules ( ) ;
4032+ pbMock ( ) ;
4033+ const orchMod = await import ( "./orchestrator.js" ) ;
4034+ const handle = await orchMod . runControlPlane (
4035+ { role : "control-plane" , poolCount : 1 } ,
4036+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4037+ ) ;
4038+ try {
4039+ const res = await fetch (
4040+ `http://127.0.0.1:${ port } /api/probes/probe:smoke/trigger` ,
4041+ { method : "POST" } ,
4042+ ) ;
4043+ expect ( res . status ) . toBe ( 401 ) ;
4044+ } finally {
4045+ await handle . stop ( ) ;
4046+ }
4047+ } ) ;
4048+
4049+ it ( "POST /api/probes/probe:e2e_smoke/trigger 404s — browser families are not in-process" , async ( ) => {
4050+ vi . resetModules ( ) ;
4051+ pbMock ( ) ;
4052+ const orchMod = await import ( "./orchestrator.js" ) ;
4053+ const handle = await orchMod . runControlPlane (
4054+ { role : "control-plane" , poolCount : 1 } ,
4055+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4056+ ) ;
4057+ try {
4058+ const res = await fetch (
4059+ `http://127.0.0.1:${ port } /api/probes/probe:e2e_smoke/trigger` ,
4060+ {
4061+ method : "POST" ,
4062+ headers : { Authorization : `Bearer ${ TOKEN } ` } ,
4063+ } ,
4064+ ) ;
4065+ // browser-only id is not a registered in-process probe → 404
4066+ expect ( res . status ) . toBe ( 404 ) ;
4067+ } finally {
4068+ await handle . stop ( ) ;
4069+ }
4070+ } ) ;
4071+
4072+ it ( "POST /api/probes/probe:nope/trigger 404s for an unknown id" , async ( ) => {
4073+ vi . resetModules ( ) ;
4074+ pbMock ( ) ;
4075+ const orchMod = await import ( "./orchestrator.js" ) ;
4076+ const handle = await orchMod . runControlPlane (
4077+ { role : "control-plane" , poolCount : 1 } ,
4078+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4079+ ) ;
4080+ try {
4081+ const res = await fetch (
4082+ `http://127.0.0.1:${ port } /api/probes/probe:nope/trigger` ,
4083+ {
4084+ method : "POST" ,
4085+ headers : { Authorization : `Bearer ${ TOKEN } ` } ,
4086+ } ,
4087+ ) ;
4088+ expect ( res . status ) . toBe ( 404 ) ;
4089+ } finally {
4090+ await handle . stop ( ) ;
4091+ }
4092+ } ) ;
4093+
4094+ it ( "does NOT mount the trigger endpoint when OPS_TRIGGER_TOKEN is unset (fail-safe)" , async ( ) => {
4095+ delete process . env . OPS_TRIGGER_TOKEN ;
4096+ vi . resetModules ( ) ;
4097+ pbMock ( ) ;
4098+ const orchMod = await import ( "./orchestrator.js" ) ;
4099+ const handle = await orchMod . runControlPlane (
4100+ { role : "control-plane" , poolCount : 1 } ,
4101+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4102+ ) ;
4103+ try {
4104+ // Router not mounted → /api/probes returns Hono's default 404.
4105+ const res = await fetch ( `http://127.0.0.1:${ port } /api/probes` ) ;
4106+ expect ( res . status ) . toBe ( 404 ) ;
4107+ } finally {
4108+ await handle . stop ( ) ;
4109+ }
4110+ } ) ;
4111+
4112+ it ( "throws fail-loud when OPS_TRIGGER_TOKEN is set-but-empty" , async ( ) => {
4113+ process . env . OPS_TRIGGER_TOKEN = " " ;
4114+ vi . resetModules ( ) ;
4115+ pbMock ( ) ;
4116+ const orchMod = await import ( "./orchestrator.js" ) ;
4117+ await expect (
4118+ orchMod . runControlPlane (
4119+ { role : "control-plane" , poolCount : 1 } ,
4120+ { port, configDir : alertsDir , fleetEnumerate : async ( ) => [ ] } ,
4121+ ) ,
4122+ ) . rejects . toThrow ( / O P S _ T R I G G E R _ T O K E N .* e m p t y / i) ;
4123+ } ) ;
4124+ } ) ;
39044125} ) ;
39054126
39064127// A4 drift-lock: the HTTP-only driver set registered by
0 commit comments