@@ -291,3 +291,142 @@ test("a driver failure surfaces as a logged 500 via onError, not an unhandled re
291291 assert . match ( errors [ 0 ] ! , / c o n t r o l _ p l a n e _ h t t p _ e r r o r / ) ;
292292 assert . match ( errors [ 0 ] ! , / c l o u d f l a r e c o n t a i n e r s a p i u n a v a i l a b l e / ) ;
293293} ) ;
294+
295+ // #4898: POST /v1/tenants/rollout — pin/unpin an explicit list of tenants' pinnedVersion, all-or-nothing.
296+ // The registry-seeding style mirrors the GET /v1/tenants tests above (records seeded directly, no driver run).
297+
298+ function rollout ( app : ReturnType < typeof createTenantHttpApp > , body : unknown ) {
299+ return app . request (
300+ "/v1/tenants/rollout" ,
301+ authed ( { method : "POST" , headers : { "content-type" : "application/json" } , body : typeof body === "string" ? body : JSON . stringify ( body ) } ) ,
302+ ) ;
303+ }
304+
305+ test ( "POST /v1/tenants/rollout pins exactly the listed tenants and leaves every other tenant untouched (#4898 acceptance)" , async ( ) => {
306+ const registry = createFakeTenantRegistry ( ) ;
307+ await registry . upsert ( { tenant : { name : "acme" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
308+ await registry . upsert ( { tenant : { name : "beta" } , product : "ams" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
309+ await registry . upsert ( { tenant : { name : "gamma" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
310+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
311+
312+ const res = await rollout ( app , { names : [ "acme" , "gamma" ] , pinnedVersion : "v1.4.2" } ) ;
313+
314+ assert . equal ( res . status , 200 ) ;
315+ const payload = ( await res . json ( ) ) as { tenants : Array < { tenant : { name : string ; pinnedVersion ?: string | null } } > } ;
316+ assert . deepEqual ( payload . tenants . map ( ( t ) => t . tenant ) , [
317+ { name : "acme" , pinnedVersion : "v1.4.2" } ,
318+ { name : "gamma" , pinnedVersion : "v1.4.2" } ,
319+ ] ) ;
320+ // The unlisted tenant is completely unaffected — no pin, no updatedAt churn.
321+ const beta = await registry . get ( "beta" ) ;
322+ assert . deepEqual ( beta ?. tenant , { name : "beta" } ) ;
323+ assert . equal ( beta ?. updatedAt , "t0" ) ;
324+ // The pinned tenants' records persisted the pin and kept their createdAt.
325+ const acme = await registry . get ( "acme" ) ;
326+ assert . deepEqual ( acme ?. tenant , { name : "acme" , pinnedVersion : "v1.4.2" } ) ;
327+ assert . equal ( acme ?. createdAt , "t0" ) ;
328+ assert . notEqual ( acme ?. updatedAt , "t0" ) ;
329+ } ) ;
330+
331+ test ( "POST /v1/tenants/rollout rolls back independently: re-pinning one tenant leaves another tenant's pin alone" , async ( ) => {
332+ const registry = createFakeTenantRegistry ( ) ;
333+ await registry . upsert ( { tenant : { name : "acme" , pinnedVersion : "v2.0.0" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
334+ await registry . upsert ( { tenant : { name : "beta" , pinnedVersion : "v2.0.0" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
335+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
336+
337+ const back = await rollout ( app , { names : [ "acme" ] , pinnedVersion : "v1.9.0" } ) ;
338+ assert . equal ( back . status , 200 ) ;
339+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" , pinnedVersion : "v1.9.0" } ) ;
340+ assert . deepEqual ( ( await registry . get ( "beta" ) ) ?. tenant , { name : "beta" , pinnedVersion : "v2.0.0" } ) ;
341+
342+ // Explicit unpin (null) reverts the tenant to its release channel's default.
343+ const unpin = await rollout ( app , { names : [ "acme" ] , pinnedVersion : null } ) ;
344+ assert . equal ( unpin . status , 200 ) ;
345+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" , pinnedVersion : null } ) ;
346+ assert . deepEqual ( ( await registry . get ( "beta" ) ) ?. tenant , { name : "beta" , pinnedVersion : "v2.0.0" } ) ;
347+ } ) ;
348+
349+ test ( "POST /v1/tenants/rollout trims the pinned version before storing it" , async ( ) => {
350+ const registry = createFakeTenantRegistry ( ) ;
351+ await registry . upsert ( { tenant : { name : "acme" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
352+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
353+
354+ const res = await rollout ( app , { names : [ "acme" ] , pinnedVersion : " v1.4.2 " } ) ;
355+
356+ assert . equal ( res . status , 200 ) ;
357+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" , pinnedVersion : "v1.4.2" } ) ;
358+ } ) ;
359+
360+ test ( "POST /v1/tenants/rollout 400s malformed bodies without touching any record" , async ( ) => {
361+ const registry = createFakeTenantRegistry ( ) ;
362+ await registry . upsert ( { tenant : { name : "acme" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
363+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
364+
365+ const notJson = await rollout ( app , "not json at all" ) ;
366+ assert . equal ( notJson . status , 400 ) ;
367+ assert . deepEqual ( await notJson . json ( ) , { error : "invalid_json" } ) ;
368+
369+ for ( const [ body , message ] of [
370+ [ [ ] , "body must be a JSON object" ] ,
371+ [ { names : [ ] , pinnedVersion : "v1" } , "names must be a non-empty array of tenant names" ] ,
372+ [ { names : "acme" , pinnedVersion : "v1" } , "names must be a non-empty array of tenant names" ] ,
373+ [ { names : [ "acme" , " " ] , pinnedVersion : "v1" } , "names must be a non-empty array of tenant names" ] ,
374+ [ { names : [ "acme" , 7 ] , pinnedVersion : "v1" } , "names must be a non-empty array of tenant names" ] ,
375+ [ { names : [ "acme" , "acme" ] , pinnedVersion : "v1" } , "names must not repeat a tenant" ] ,
376+ [ { names : [ "acme" ] , pinnedVersion : " " } , "pinnedVersion must be a non-blank string, or null to unpin" ] ,
377+ [ { names : [ "acme" ] , pinnedVersion : 7 } , "pinnedVersion must be a non-blank string, or null to unpin" ] ,
378+ [ { names : [ "acme" ] } , "pinnedVersion must be a non-blank string, or null to unpin" ] ,
379+ ] as const ) {
380+ const res = await rollout ( app , body ) ;
381+ assert . equal ( res . status , 400 , JSON . stringify ( body ) ) ;
382+ assert . deepEqual ( await res . json ( ) , { error : "invalid_request" , message } ) ;
383+ }
384+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" } ) ;
385+ } ) ;
386+
387+ test ( "POST /v1/tenants/rollout is all-or-nothing: one unknown name 404s and applies nothing" , async ( ) => {
388+ const registry = createFakeTenantRegistry ( ) ;
389+ await registry . upsert ( { tenant : { name : "acme" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
390+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
391+
392+ const res = await rollout ( app , { names : [ "acme" , "ghost" ] , pinnedVersion : "v1.4.2" } ) ;
393+
394+ assert . equal ( res . status , 404 ) ;
395+ assert . deepEqual ( await res . json ( ) , { error : "tenant_not_found" , message : 'unknown tenant "ghost"' } ) ;
396+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" } ) ;
397+ } ) ;
398+
399+ test ( "POST /v1/tenants/rollout 409s a torn-down tenant and applies nothing" , async ( ) => {
400+ const registry = createFakeTenantRegistry ( ) ;
401+ await registry . upsert ( { tenant : { name : "acme" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
402+ await registry . upsert ( { tenant : { name : "gone" } , product : "orb" , state : "torn down" , createdAt : "t0" , updatedAt : "t0" } ) ;
403+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
404+
405+ const res = await rollout ( app , { names : [ "acme" , "gone" ] , pinnedVersion : "v1.4.2" } ) ;
406+
407+ assert . equal ( res . status , 409 ) ;
408+ assert . deepEqual ( await res . json ( ) , { error : "tenant_torn_down" , message : 'tenant "gone" is torn down' } ) ;
409+ assert . deepEqual ( ( await registry . get ( "acme" ) ) ?. tenant , { name : "acme" } ) ;
410+ } ) ;
411+
412+ test ( "POST /v1/tenants/rollout sits behind the same Bearer wall as every other /v1/tenants route" , async ( ) => {
413+ const app = createTenantHttpApp ( baseDeps ( ) ) ;
414+
415+ const res = await app . request ( "/v1/tenants/rollout" , { method : "POST" , body : JSON . stringify ( { names : [ "acme" ] , pinnedVersion : "v1" } ) } ) ;
416+
417+ assert . equal ( res . status , 401 ) ;
418+ assert . deepEqual ( await res . json ( ) , { error : "unauthorized" } ) ;
419+ } ) ;
420+
421+ test ( "GET /v1/tenants surfaces each tenant's pinnedVersion once one is set (#4898 admin visibility)" , async ( ) => {
422+ const registry = createFakeTenantRegistry ( ) ;
423+ await registry . upsert ( { tenant : { name : "acme" , pinnedVersion : "v1.4.2" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ) ;
424+ const app = createTenantHttpApp ( baseDeps ( { registry } ) ) ;
425+
426+ const res = await app . request ( "/v1/tenants" , authed ( ) ) ;
427+
428+ assert . equal ( res . status , 200 ) ;
429+ assert . deepEqual ( await res . json ( ) , {
430+ tenants : [ { tenant : { name : "acme" , pinnedVersion : "v1.4.2" } , product : "orb" , state : "active" , createdAt : "t0" , updatedAt : "t0" } ] ,
431+ } ) ;
432+ } ) ;
0 commit comments