22import os
33import time
44import unittest
5+ from contextlib import contextmanager
56from unittest .mock import patch
67
78import cloudinary
1112from test .oauth_helpers import jwt_access_token
1213from cloudinary_cli .cli import cli
1314from cloudinary_cli .utils .config_resolver import config_to_api_kwargs , get_cloudinary_config
15+ from cloudinary_cli .utils import config_utils
1416from cloudinary_cli .utils .config_utils import config_to_dict , show_cloudinary_config
1517
1618
@@ -20,6 +22,27 @@ def _oauth_url(cloud="eu-cloud", region="api-eu"):
2022 expires_at = int (time .time ()) + 300 , region = region , issuer = "https://oauth.cloudinary.com/" ))
2123
2224
25+ @contextmanager
26+ def _patched_config_store (initial = None ):
27+ """Back load_config/save_config with an in-memory dict (no real config.json or lock)."""
28+ store = {"cfg" : dict (initial or {})}
29+
30+ @contextmanager
31+ def _noop_lock ():
32+ yield
33+
34+ def _load ():
35+ cfg = dict (store ["cfg" ])
36+ config_utils ._rectify_config (cfg ) # the real load_config rectifies; mirror it here
37+ return cfg
38+
39+ with patch ("cloudinary_cli.utils.config_utils.load_config" , side_effect = _load ), \
40+ patch ("cloudinary_cli.utils.config_utils.save_config" ,
41+ side_effect = lambda cfg : store .__setitem__ ("cfg" , dict (cfg ))), \
42+ patch ("cloudinary_cli.utils.config_utils.config_lock" , _noop_lock ):
43+ yield store
44+
45+
2346class _RestoresSdkConfig (unittest .TestCase ):
2447 def setUp (self ):
2548 self ._env_snapshot = dict (os .environ )
@@ -43,7 +66,7 @@ def test_removes_oauth_login(self):
4366 from cloudinary_cli .auth import logout
4467 saved = {"eu-cloud" : _oauth_url ()}
4568 with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
46- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove , \
69+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove , \
4770 patch ("cloudinary_cli.auth.flow.revoke" ) as revoke :
4871 self .assertEqual ("removed" , logout ("eu-cloud" ))
4972 remove .assert_called_once_with ("eu-cloud" )
@@ -54,7 +77,7 @@ def test_revoke_failure_still_removes_locally(self):
5477 from cloudinary_cli .auth import logout
5578 saved = {"eu-cloud" : _oauth_url ()}
5679 with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
57- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove , \
80+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove , \
5881 patch ("cloudinary_cli.auth.flow.revoke" , side_effect = requests .ConnectionError ()):
5982 self .assertEqual ("revoke_failed" , logout ("eu-cloud" ))
6083 remove .assert_called_once_with ("eu-cloud" ) # local entry removed despite revoke failure
@@ -63,14 +86,14 @@ def test_refuses_non_oauth_config(self):
6386 from cloudinary_cli .auth import logout
6487 saved = {"mykey" : "cloudinary://key:secret@cloud" }
6588 with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
66- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
89+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
6790 self .assertEqual ("not_oauth" , logout ("mykey" ))
6891 remove .assert_not_called ()
6992
7093 def test_missing_name (self ):
7194 from cloudinary_cli .auth import logout
7295 with patch ("cloudinary_cli.auth.load_config" , return_value = {}), \
73- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
96+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
7497 self .assertEqual ("not_found" , logout ("nope" ))
7598 remove .assert_not_called ()
7699
@@ -79,13 +102,15 @@ class TestLogoutInteractiveSelect(unittest.TestCase):
79102 """`cld logout` with no name lists OAuth logins and removes the chosen one."""
80103
81104 runner = CliRunner ()
105+ # The numbered menu only appears with more than one login, so menu tests need two.
106+ _TWO_LOGINS = {"cloud-a" : _oauth_url ("cloud-a" ), "cloud-b" : _oauth_url ("cloud-b" )}
82107
83108 def test_lists_only_oauth_and_removes_selected (self ):
84109 saved = {"mykey" : "cloudinary://key:secret@cloud" ,
85110 "cloud-a" : _oauth_url ("cloud-a" ), "cloud-b" : _oauth_url ("cloud-b" )}
86111 with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
87112 patch ("cloudinary_cli.auth.refresh.load_config" , return_value = saved ), \
88- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove , \
113+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove , \
89114 patch ("cloudinary_cli.auth.flow.revoke" ):
90115 result = self .runner .invoke (cli , ["logout" ], input = "2\n " )
91116 self .assertIn ("cloud-a" , result .output )
@@ -96,40 +121,60 @@ def test_lists_only_oauth_and_removes_selected(self):
96121 def test_no_oauth_logins (self ):
97122 with patch ("cloudinary_cli.auth.refresh.load_config" ,
98123 return_value = {"mykey" : "cloudinary://key:secret@cloud" }), \
99- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
124+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
100125 result = self .runner .invoke (cli , ["logout" ], input = "\n " )
101126 self .assertIn ("No saved OAuth logins" , result .output )
102127 remove .assert_not_called ()
103128
104129 def test_cancel_on_empty_input (self ):
105130 with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = {"cloud-a" : _oauth_url ("cloud-a" )}), \
106- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
131+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
107132 result = self .runner .invoke (cli , ["logout" ], input = "\n " )
108133 remove .assert_not_called ()
109134 self .assertEqual (0 , result .exit_code )
110135
111136 def test_invalid_non_numeric_errors (self ):
112- with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = { "cloud-a" : _oauth_url ( "cloud-a" )} ), \
113- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
137+ with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = self . _TWO_LOGINS ), \
138+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
114139 result = self .runner .invoke (cli , ["logout" ], input = "sdfdsf\n " , standalone_mode = False )
115140 self .assertIn ("Invalid selection" , result .output )
116141 self .assertFalse (result .return_value ) # main() maps falsy -> exit 1
117142 remove .assert_not_called ()
118143
119144 def test_out_of_range_errors (self ):
120- with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = { "cloud-a" : _oauth_url ( "cloud-a" )} ), \
121- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove :
145+ with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = self . _TWO_LOGINS ), \
146+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove :
122147 result = self .runner .invoke (cli , ["logout" ], input = "5\n " , standalone_mode = False )
123148 self .assertIn ("Invalid selection" , result .output )
124149 self .assertFalse (result .return_value )
125150 remove .assert_not_called ()
126151
152+ def test_sole_login_confirms_instead_of_listing (self ):
153+ saved = {"cloud-a" : _oauth_url ("cloud-a" )}
154+ with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
155+ patch ("cloudinary_cli.auth.refresh.load_config" , return_value = saved ), \
156+ patch ("cloudinary_cli.auth.remove_named_config" ) as remove , \
157+ patch ("cloudinary_cli.auth.flow.revoke" ):
158+ result = self .runner .invoke (cli , ["logout" ], input = "y\n " )
159+ self .assertNotIn ("Saved OAuth logins:" , result .output ) # no one-item menu
160+ self .assertIn ("cloud-a" , result .output )
161+ remove .assert_called_once_with ("cloud-a" )
162+
163+ def test_sole_login_declined_removes_nothing (self ):
164+ saved = {"cloud-a" : _oauth_url ("cloud-a" )}
165+ with patch ("cloudinary_cli.auth.load_config" , return_value = saved ), \
166+ patch ("cloudinary_cli.auth.refresh.load_config" , return_value = saved ), \
167+ patch ("cloudinary_cli.auth.remove_named_config" ) as remove :
168+ result = self .runner .invoke (cli , ["logout" ], input = "n\n " )
169+ remove .assert_not_called ()
170+ self .assertEqual (0 , result .exit_code )
171+
127172 def test_noninteractive_stdin_errors_with_hint (self ):
128173 # Closed stdin (no input at all): the selection cannot be made, so error with the
129174 # non-interactive form (`cld logout <name>`) and exit non-zero, not a silent no-op.
130175 import builtins
131176 with patch ("cloudinary_cli.auth.refresh.load_config" , return_value = {"cloud-a" : _oauth_url ("cloud-a" )}), \
132- patch ("cloudinary_cli.auth.remove_config_keys " ) as remove , \
177+ patch ("cloudinary_cli.auth.remove_named_config " ) as remove , \
133178 patch .object (builtins , "input" , side_effect = EOFError ()):
134179 result = self .runner .invoke (cli , ["logout" ], standalone_mode = False )
135180 self .assertIn ("cld logout <name>" , result .output )
@@ -811,12 +856,11 @@ def test_synthetic_row_name_parenthesized_in_table_and_json(self):
811856 self .assertIn ("(environment)" , by_name )
812857
813858 def test_rm_of_default_clears_it (self ):
814- with patch ("cloudinary_cli.core.config.remove_config_keys" , return_value = []), \
815- patch ("cloudinary_cli.core.config.get_default_config_name" , return_value = "prod" ), \
816- patch ("cloudinary_cli.core.config.clear_default_config" ) as clear :
859+ stored = {"prod" : "cloudinary://k:s@prod" , "__default__" : "prod" }
860+ with _patched_config_store (stored ) as store :
817861 result = self .runner .invoke (cli , ['config' , '-rm' , 'prod' ])
818862 self .assertEqual (0 , result .exit_code , result .output )
819- clear . assert_called_once ()
863+ self . assertEqual ({}, store [ "cfg" ]) # the name and the default it held both gone
820864
821865 def test_reserved_name_rejected_on_new (self ):
822866 result = self .runner .invoke (
0 commit comments