@@ -30,6 +30,8 @@ import {
3030 fetchStackFamily ,
3131 listDeployments ,
3232 getDeployment ,
33+ createDeploy ,
34+ updateDeploymentAccess ,
3335 reportExperimentConverted ,
3436 validatePromotion ,
3537} from './index'
@@ -963,6 +965,173 @@ describe('getDeployment()', () => {
963965 m . mockResolvedValueOnce ( jsonResponse ( { error : 'boom' } , { status : 500 } ) )
964966 await expect ( getDeployment ( 'd1' ) ) . rejects . toMatchObject ( { status : 500 } )
965967 } )
968+
969+ it ( 'surfaces private + allowed_ips when the API returns them' , async ( ) => {
970+ const m = installFetch ( )
971+ m . mockResolvedValueOnce ( jsonResponse ( {
972+ ok : true ,
973+ item : {
974+ id : 'd1' , app_id : 'd1' , status : 'running' , port : 8080 , tier : 'pro' ,
975+ env : { } , environment : 'production' , created_at : 'x' , updated_at : 'x' ,
976+ private : true ,
977+ allowed_ips : [ '8.8.8.8' , '10.0.0.0/8' ] ,
978+ } ,
979+ } ) )
980+ const r = await getDeployment ( 'd1' )
981+ expect ( r . deployment ?. private ) . toBe ( true )
982+ expect ( r . deployment ?. allowed_ips ) . toEqual ( [ '8.8.8.8' , '10.0.0.0/8' ] )
983+ } )
984+
985+ it ( 'defaults private=false and allowed_ips=[] when the API omits both' , async ( ) => {
986+ // Older Track A builds don't expose the privacy fields. The adapter
987+ // must NOT silently inherit `private` from a stale frontend cache —
988+ // it should normalise to false.
989+ const m = installFetch ( )
990+ m . mockResolvedValueOnce ( jsonResponse ( {
991+ ok : true ,
992+ item : {
993+ id : 'd1' , app_id : 'd1' , status : 'running' , port : 8080 , tier : 'pro' ,
994+ env : { } , environment : 'production' , created_at : 'x' , updated_at : 'x' ,
995+ } ,
996+ } ) )
997+ const r = await getDeployment ( 'd1' )
998+ expect ( r . deployment ?. private ) . toBe ( false )
999+ expect ( r . deployment ?. allowed_ips ) . toEqual ( [ ] )
1000+ } )
1001+ } )
1002+
1003+ // ─── createDeploy() — POST /deploy/new with private + allowed_ips ────────
1004+ // The dashboard's createDeploy helper is the wire-shape source of truth
1005+ // for the private-deploy fields. The agent prompt on DeploymentsPage
1006+ // renders these same keys, so a contract drift here would silently leak
1007+ // into the prompt copy. We lock the field names + path explicitly.
1008+ describe ( 'createDeploy()' , ( ) => {
1009+ it ( 'POSTs to /deploy/new with private + allowed_ips in the body' , async ( ) => {
1010+ const m = installFetch ( )
1011+ m . mockResolvedValueOnce ( jsonResponse ( {
1012+ ok : true ,
1013+ item : {
1014+ id : 'd1' , app_id : 'd1' , status : 'building' , port : 8080 , tier : 'pro' ,
1015+ env : { FOO : 'bar' } , environment : 'production' ,
1016+ created_at : 'x' , updated_at : 'x' ,
1017+ private : true , allowed_ips : [ '8.8.8.8' ] ,
1018+ } ,
1019+ } ) )
1020+ const r = await createDeploy ( {
1021+ name : 'my-app' ,
1022+ port : 8080 ,
1023+ env : 'production' ,
1024+ env_vars : { FOO : 'bar' } ,
1025+ private : true ,
1026+ allowed_ips : [ '8.8.8.8' ] ,
1027+ } )
1028+ expect ( r . ok ) . toBe ( true )
1029+ expect ( r . deployment . private ) . toBe ( true )
1030+ expect ( r . deployment . allowed_ips ) . toEqual ( [ '8.8.8.8' ] )
1031+
1032+ const [ url , init ] = m . mock . calls [ 0 ]
1033+ expect ( String ( url ) ) . toContain ( '/deploy/new' )
1034+ expect ( init ?. method ) . toBe ( 'POST' )
1035+ const sent = JSON . parse ( String ( init ! . body ) )
1036+ expect ( sent . private ) . toBe ( true )
1037+ expect ( sent . allowed_ips ) . toEqual ( [ '8.8.8.8' ] )
1038+ // env_vars rides under the server's legacy `env` alias; the env scope
1039+ // goes in `environment`.
1040+ expect ( sent . env ) . toEqual ( { FOO : 'bar' } )
1041+ expect ( sent . environment ) . toBe ( 'production' )
1042+ } )
1043+
1044+ it ( 'omits private + allowed_ips when caller does not pass them (public deploy)' , async ( ) => {
1045+ const m = installFetch ( )
1046+ m . mockResolvedValueOnce ( jsonResponse ( {
1047+ ok : true ,
1048+ item : {
1049+ id : 'd1' , app_id : 'd1' , status : 'building' , port : 8080 , tier : 'free' ,
1050+ env : { } , environment : 'production' ,
1051+ created_at : 'x' , updated_at : 'x' ,
1052+ } ,
1053+ } ) )
1054+ await createDeploy ( { name : 'my-app' , port : 8080 , env : 'production' } )
1055+ const sent = JSON . parse ( String ( m . mock . calls [ 0 ] [ 1 ] ! . body ) )
1056+ expect ( sent ) . not . toHaveProperty ( 'private' )
1057+ expect ( sent ) . not . toHaveProperty ( 'allowed_ips' )
1058+ } )
1059+
1060+ it ( 'propagates 402 (tier gate) so the page can render an upgrade prompt' , async ( ) => {
1061+ const m = installFetch ( )
1062+ m . mockResolvedValueOnce ( jsonResponse (
1063+ { error : 'upgrade_required' , agent_action : 'upgrade_to_pro' } ,
1064+ { status : 402 } ,
1065+ ) )
1066+ await expect (
1067+ createDeploy ( { private : true , allowed_ips : [ '8.8.8.8' ] } ) ,
1068+ ) . rejects . toMatchObject ( { status : 402 } )
1069+ } )
1070+
1071+ it ( 'propagates 400 (validation_error) so the page can show inline IP errors' , async ( ) => {
1072+ const m = installFetch ( )
1073+ m . mockResolvedValueOnce ( jsonResponse (
1074+ { error : 'validation_error' , message : 'allowed_ips empty when private=true' } ,
1075+ { status : 400 } ,
1076+ ) )
1077+ await expect (
1078+ createDeploy ( { private : true , allowed_ips : [ ] } ) ,
1079+ ) . rejects . toMatchObject ( { status : 400 } )
1080+ } )
1081+ } )
1082+
1083+ // ─── updateDeploymentAccess() — PATCH /api/v1/deployments/:id ────────────
1084+ // Track A's PATCH endpoint is still in flight. The dashboard helper still
1085+ // issues the request — a 404 means "endpoint not yet shipped" and the
1086+ // caller (PrivacyPanel on DeployDetailPage) surfaces a friendly hint.
1087+ describe ( 'updateDeploymentAccess()' , ( ) => {
1088+ it ( 'PATCHes /api/v1/deployments/:id with private + allowed_ips' , async ( ) => {
1089+ const m = installFetch ( )
1090+ m . mockResolvedValueOnce ( jsonResponse ( {
1091+ ok : true ,
1092+ item : {
1093+ id : 'd1' , app_id : 'd1' , status : 'running' , port : 8080 , tier : 'pro' ,
1094+ env : { } , environment : 'production' ,
1095+ created_at : 'x' , updated_at : 'y' ,
1096+ private : true , allowed_ips : [ '1.1.1.1' ] ,
1097+ } ,
1098+ } ) )
1099+ const r = await updateDeploymentAccess ( 'd1' , true , [ '1.1.1.1' ] )
1100+ expect ( r . deployment . private ) . toBe ( true )
1101+ expect ( r . deployment . allowed_ips ) . toEqual ( [ '1.1.1.1' ] )
1102+ const [ url , init ] = m . mock . calls [ 0 ]
1103+ expect ( String ( url ) ) . toContain ( '/api/v1/deployments/d1' )
1104+ expect ( init ?. method ) . toBe ( 'PATCH' )
1105+ const sent = JSON . parse ( String ( init ! . body ) )
1106+ expect ( sent ) . toEqual ( { private : true , allowed_ips : [ '1.1.1.1' ] } )
1107+ } )
1108+
1109+ it ( 'URI-encodes the deployment id' , async ( ) => {
1110+ const m = installFetch ( )
1111+ m . mockResolvedValueOnce ( jsonResponse ( {
1112+ ok : true ,
1113+ item : { id : 'd weird' , app_id : 'd' , status : 'running' , port : 1 , tier : 'pro' , env : { } , environment : 'production' , created_at : 'x' , updated_at : 'y' } ,
1114+ } ) )
1115+ await updateDeploymentAccess ( 'd weird' , false , [ ] )
1116+ expect ( String ( m . mock . calls [ 0 ] [ 0 ] ) ) . toContain ( '/api/v1/deployments/d%20weird' )
1117+ } )
1118+
1119+ it ( 'propagates 404 so the page can surface "edits pending backend"' , async ( ) => {
1120+ const m = installFetch ( )
1121+ m . mockResolvedValueOnce ( jsonResponse ( { error : 'not_found' } , { status : 404 } ) )
1122+ await expect ( updateDeploymentAccess ( 'd1' , true , [ '8.8.8.8' ] ) )
1123+ . rejects . toMatchObject ( { status : 404 } )
1124+ } )
1125+
1126+ it ( 'propagates 402 (tier gate)' , async ( ) => {
1127+ const m = installFetch ( )
1128+ m . mockResolvedValueOnce ( jsonResponse (
1129+ { error : 'upgrade_required' , agent_action : 'upgrade_to_pro' } ,
1130+ { status : 402 } ,
1131+ ) )
1132+ await expect ( updateDeploymentAccess ( 'd1' , true , [ '8.8.8.8' ] ) )
1133+ . rejects . toMatchObject ( { status : 402 } )
1134+ } )
9661135} )
9671136
9681137// ─── fetchStackFamily() — Pro+ env grid loader ───────────────────────────
0 commit comments