@@ -130,7 +130,7 @@ def test_sealed_payload_round_trip_and_normalized_pins(self):
130130 self .subTest (pin = pin ),
131131 patch .object (VMM_CLI , "load_whitelist" , return_value = [pin ]),
132132 ):
133- self .decrypt (self .cli .encrypt_env_payload (self .envs , self .app_id , None ))
133+ self .decrypt (self .cli .encrypt_env_payload (self .envs , self .app_id , None , True ))
134134 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
135135
136136 def test_app_epoch_is_signed_and_used_for_sealing (self ):
@@ -142,7 +142,7 @@ def test_app_epoch_is_signed_and_used_for_sealing(self):
142142 + self .timestamp .to_bytes (8 , "big" )
143143 )
144144 self .response ["signature" ] = self .signer .sign_msg_hash (digest ).to_bytes ().hex ()
145- payload = self .cli .encrypt_env_payload (self .envs , self .app_id , None )
145+ payload = self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
146146 envelope = json .loads (bytes .fromhex (payload ["sealed_env" ]))
147147 self .assertEqual (envelope ["version" ], 1 )
148148 self .assertEqual (envelope ["app_epoch" ], 2 )
@@ -154,7 +154,7 @@ def test_app_epoch_is_signed_and_used_for_sealing(self):
154154 self .assertEqual (plaintext , {"env" : [{"key" : k , "value" : v } for k , v in self .envs .items ()]})
155155 self .response ["app_epoch" ] = 1
156156 with self .assertRaises (ValueError ):
157- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
157+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
158158
159159 def test_unpinned_signer_never_becomes_trusted (self ):
160160 for pins in [
@@ -167,7 +167,7 @@ def test_unpinned_signer_never_becomes_trusted(self):
167167 patch .object (self .cli , "confirm_untrusted_signer" ) as confirm ,
168168 ):
169169 with self .assertRaisesRegex (ValueError , "not pinned" ):
170- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
170+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
171171 confirm .assert_not_called ()
172172 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
173173
@@ -193,25 +193,25 @@ def test_signature_binds_every_field_and_app(self):
193193 {** self .response , field : value },
194194 )
195195 with self .assertRaises (Exception ):
196- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
196+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
197197 self .cli .client .request .return_value = (200 , self .response )
198198 for app_id in ["55" * 20 , "11" * 19 ]:
199199 with self .assertRaises (ValueError ):
200- self .cli .encrypt_env_payload (self .envs , app_id , None )
200+ self .cli .encrypt_env_payload (self .envs , app_id , None , True )
201201 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
202202
203203 def test_pinned_signed_key_timestamp_window (self ):
204204 for age in [- 60 , 0 , 300 ]:
205205 with self .subTest (age = age ), patch .object (
206206 VMM_CLI .time , "time" , return_value = self .timestamp + age
207207 ):
208- self .decrypt (self .cli .encrypt_env_payload (self .envs , self .app_id , None ))
208+ self .decrypt (self .cli .encrypt_env_payload (self .envs , self .app_id , None , True ))
209209 for age , reason in [(- 61 , "future" ), (301 , "stale" )]:
210210 with self .subTest (age = age ), patch .object (
211211 VMM_CLI .time , "time" , return_value = self .timestamp + age
212212 ):
213213 with self .assertRaisesRegex (ValueError , reason ):
214- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
214+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
215215 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
216216
217217 def test_public_key_request_contains_only_app_id (self ):
@@ -228,14 +228,21 @@ def test_only_explicit_unsupported_method_falls_back(self):
228228 status ,
229229 {"error" : "Service not found: GetSealedEnvPubKey" },
230230 )
231- payload = self .cli .encrypt_env_payload (self .envs , self .app_id , None )
231+ payload = self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
232232 self .assertEqual (set (payload ), {"encrypted_env" })
233233 self .cli .get_app_env_encrypt_pub_key .assert_called_with (self .app_id , None )
234+ for upstream in ["400 Bad Request" , "404 Not Found" ]:
235+ self .cli .client .request .return_value = (400 , {
236+ "error" : f"Request failed with status={ upstream } , error="
237+ + json .dumps ({"error" : "Service not found: GetSealedEnvPubKey" })
238+ })
239+ payload = self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
240+ self .assertEqual (set (payload ), {"encrypted_env" })
234241 self .cli .get_app_env_encrypt_pub_key .side_effect = ValueError (
235242 "legacy trust rejected"
236243 )
237244 with self .assertRaisesRegex (ValueError , "legacy trust rejected" ):
238- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
245+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
239246
240247 def test_errors_and_bad_responses_do_not_downgrade (self ):
241248 for status , body in [
@@ -244,29 +251,53 @@ def test_errors_and_bad_responses_do_not_downgrade(self):
244251 (404 , "Not Found" ),
245252 (400 , {"error" : "invalid app" }),
246253 (500 , {"error" : "unavailable" }),
254+ (400 , {"error" : 'Request failed with status=500 Internal Server Error, error={"error":"Service not found: GetSealedEnvPubKey"}' }),
255+ (400 , {"error" : 'Request failed with status=404 Not Found, error={"error":"other service"}' }),
256+ (400 , {"error" : 'Request failed with status=404 Not Found, error=invalid' }),
247257 (200 , {}),
248258 (200 , "invalid JSON" ),
249259 ]:
250260 with self .subTest (status = status , body = body ):
251261 self .cli .client .request .return_value = (status , body )
252262 with self .assertRaises (Exception ):
253- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
263+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
254264 self .cli .client .request .side_effect = TimeoutError ("test timeout" )
255265 with self .assertRaises (TimeoutError ):
256- self .cli .encrypt_env_payload (self .envs , self .app_id , None )
266+ self .cli .encrypt_env_payload (self .envs , self .app_id , None , True )
257267 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
258268
259269 def test_direct_kms_verifies_pin_and_does_not_forward_vmm_credentials (self ):
260270 with patch .object (VMM_CLI , "VmmClient" ) as client :
261271 client .return_value .request .return_value = (200 , self .response )
262272 self .decrypt (
263273 self .cli .encrypt_env_payload (
264- self .envs , self .app_id , "https://kms.example"
274+ self .envs , self .app_id , "https://kms.example" , True
265275 )
266276 )
267277 client .assert_called_once_with ("https://kms.example" , verify_tls = False )
268278 self .cli .get_app_env_encrypt_pub_key .assert_not_called ()
269279
280+ def test_image_downgrade_and_env_replacement_share_one_request (self ):
281+ def rpc (method , params = None ):
282+ if method == "GetInfo" :
283+ return {"found" : True , "info" : {"app_id" : self .app_id , "configuration" : {
284+ "image" : "new" , "compose_file" : '{"kms_enabled":true}' ,
285+ }}}
286+ if method == "ListImages" :
287+ return {"images" : [{"name" : "new" , "sealed_env" : True }, {"name" : "old" }]}
288+ return {}
289+ self .cli .rpc_call = Mock (side_effect = rpc )
290+ with patch .object (VMM_CLI , "parse_env_file" , return_value = self .envs ), contextlib .redirect_stdout (io .StringIO ()):
291+ self .cli .update_vm ("vm" , image = "old" , env_file = "env" , kms_urls = ["https://kms.example" ])
292+ calls = self .cli .rpc_call .call_args_list
293+ self .assertNotIn ("ResizeVm" , [call .args [0 ] for call in calls ])
294+ method , payload = calls [- 1 ].args
295+ self .assertEqual (method , "UpgradeApp" )
296+ self .assertEqual (payload ["image" ], "old" )
297+ self .assertIn ("encrypted_env" , payload )
298+ self .assertNotIn ("sealed_env" , payload )
299+ self .cli .client .request .assert_not_called ()
300+
270301 def test_create_update_and_update_env_send_only_the_negotiated_field (self ):
271302 compose = json .dumps ({"kms_enabled" : True })
272303 args = argparse .Namespace (
@@ -299,12 +330,14 @@ def test_create_update_and_update_env_send_only_the_negotiated_field(self):
299330 )
300331
301332 def rpc (method , params = None ):
333+ if method == "ListImages" :
334+ return {"images" : [{"name" : "image" , ** capability }]}
302335 if method == "GetInfo" :
303336 return {
304337 "found" : True ,
305338 "info" : {
306339 "app_id" : self .app_id ,
307- "configuration" : {"compose_file" : compose },
340+ "configuration" : {"compose_file" : compose , "image" : "image" },
308341 },
309342 }
310343 return {"id" : "vm" }
@@ -317,7 +350,7 @@ def rpc(method, params=None):
317350 patch .object (VMM_CLI , "VmmClient" ) as client ,
318351 contextlib .redirect_stdout (io .StringIO ()),
319352 ):
320- for supported in [True , False ]:
353+ for capability , supported in [({ "sealed_env" : True }, True ), ({ "sealed_env" : True }, False ), ({ "sealed_env" : False }, True ), ({}, True ) ]:
321354 client .return_value .request .return_value = (
322355 (200 , self .response )
323356 if supported
@@ -335,7 +368,7 @@ def rpc(method, params=None):
335368 action ()
336369 method , payload = self .cli .rpc_call .call_args .args
337370 self .assertIn (method , ["CreateVm" , "UpgradeApp" ])
338- if supported :
371+ if supported and capability . get ( "sealed_env" ) :
339372 self .decrypt (payload )
340373 else :
341374 self .assertIn ("encrypted_env" , payload )
0 commit comments