@@ -853,3 +853,278 @@ async def test_no_prompt_error_contains_interactive_login(
853853 msg = str (exc_info .value )
854854 assert msg .startswith ("Interactive login is required" )
855855 assert "Auth URI:" in msg
856+
857+
858+ # ---------------------------------------------------------------------------
859+ # TokenAuth -- callable(self._token) mutant killer
860+ # ---------------------------------------------------------------------------
861+
862+
863+ class TestTokenAuthCallableMutant :
864+ """Kill mutant: callable(self._token) → callable(None)."""
865+
866+ async def test_callable_token_is_awaited_not_returned_raw (self ):
867+ """If callable() were always False, we'd get the coroutine function back."""
868+
869+ async def factory () -> str :
870+ return "awaited-value"
871+
872+ auth = TokenAuth (factory )
873+ result = await auth .get_token ()
874+ # Must be the awaited string, not the coroutine function itself
875+ assert result == "awaited-value"
876+ assert isinstance (result , str )
877+ assert not callable (result )
878+
879+ async def test_string_token_not_awaited (self ):
880+ """String tokens must be returned as-is (not called)."""
881+ auth = TokenAuth ("plain" )
882+ result = await auth .get_token ()
883+ assert result == "plain"
884+
885+
886+ # ---------------------------------------------------------------------------
887+ # MsalAuth._build_app -- direct call tests to kill constructor-arg mutants
888+ # ---------------------------------------------------------------------------
889+
890+
891+ class TestBuildAppDirect :
892+ """Call _build_app() directly to kill mutants on constructor args."""
893+
894+ @patch ("flameconnect.auth.msal" )
895+ def test_returns_app_and_cache (self , mock_msal , tmp_path ):
896+ """_build_app returns (app, cache) tuple."""
897+ cache_path = tmp_path / "token.json"
898+ mock_cache = MagicMock ()
899+ mock_msal .SerializableTokenCache .return_value = mock_cache
900+ mock_app = MagicMock ()
901+ mock_msal .PublicClientApplication .return_value = mock_app
902+
903+ auth = MsalAuth (cache_path = cache_path )
904+ app , cache = auth ._build_app ()
905+
906+ assert app is mock_app
907+ assert cache is mock_cache
908+
909+ @patch ("flameconnect.auth.msal" )
910+ def test_serializable_token_cache_called (self , mock_msal , tmp_path ):
911+ """msal.SerializableTokenCache() is called (not replaced with None)."""
912+ cache_path = tmp_path / "token.json"
913+ mock_cache = MagicMock ()
914+ mock_msal .SerializableTokenCache .return_value = mock_cache
915+ mock_msal .PublicClientApplication .return_value = MagicMock ()
916+
917+ auth = MsalAuth (cache_path = cache_path )
918+ auth ._build_app ()
919+
920+ mock_msal .SerializableTokenCache .assert_called_once_with ()
921+
922+ @patch ("flameconnect.auth.msal" )
923+ def test_cache_not_deserialized_when_file_missing (self , mock_msal , tmp_path ):
924+ """When cache file doesn't exist, deserialize is NOT called."""
925+ cache_path = tmp_path / "nonexistent.json"
926+ mock_cache = MagicMock ()
927+ mock_msal .SerializableTokenCache .return_value = mock_cache
928+ mock_msal .PublicClientApplication .return_value = MagicMock ()
929+
930+ auth = MsalAuth (cache_path = cache_path )
931+ auth ._build_app ()
932+
933+ mock_cache .deserialize .assert_not_called ()
934+
935+ @patch ("flameconnect.auth.msal" )
936+ def test_cache_deserialized_with_file_text (self , mock_msal , tmp_path ):
937+ """When cache file exists, deserialize receives its text content."""
938+ cache_path = tmp_path / "token.json"
939+ cache_path .write_text ('{"cached": true}' )
940+
941+ mock_cache = MagicMock ()
942+ mock_msal .SerializableTokenCache .return_value = mock_cache
943+ mock_msal .PublicClientApplication .return_value = MagicMock ()
944+
945+ auth = MsalAuth (cache_path = cache_path )
946+ auth ._build_app ()
947+
948+ mock_cache .deserialize .assert_called_once_with ('{"cached": true}' )
949+
950+ @patch ("flameconnect.auth.msal" )
951+ def test_public_client_app_receives_client_id (self , mock_msal , tmp_path ):
952+ """CLIENT_ID is the first positional arg."""
953+ cache_path = tmp_path / "token.json"
954+ mock_cache = MagicMock ()
955+ mock_msal .SerializableTokenCache .return_value = mock_cache
956+ mock_msal .PublicClientApplication .return_value = MagicMock ()
957+
958+ auth = MsalAuth (cache_path = cache_path )
959+ auth ._build_app ()
960+
961+ args , kwargs = mock_msal .PublicClientApplication .call_args
962+ assert args == (CLIENT_ID ,)
963+
964+ @patch ("flameconnect.auth.msal" )
965+ def test_public_client_app_receives_authority (self , mock_msal , tmp_path ):
966+ """authority=AUTHORITY is passed."""
967+ cache_path = tmp_path / "token.json"
968+ mock_msal .SerializableTokenCache .return_value = MagicMock ()
969+ mock_msal .PublicClientApplication .return_value = MagicMock ()
970+
971+ auth = MsalAuth (cache_path = cache_path )
972+ auth ._build_app ()
973+
974+ _ , kwargs = mock_msal .PublicClientApplication .call_args
975+ assert kwargs ["authority" ] is AUTHORITY
976+
977+ @patch ("flameconnect.auth.msal" )
978+ def test_public_client_app_validate_authority_false (self , mock_msal , tmp_path ):
979+ """validate_authority=False (not True, not missing)."""
980+ cache_path = tmp_path / "token.json"
981+ mock_msal .SerializableTokenCache .return_value = MagicMock ()
982+ mock_msal .PublicClientApplication .return_value = MagicMock ()
983+
984+ auth = MsalAuth (cache_path = cache_path )
985+ auth ._build_app ()
986+
987+ _ , kwargs = mock_msal .PublicClientApplication .call_args
988+ assert kwargs ["validate_authority" ] is False
989+
990+ @patch ("flameconnect.auth.msal" )
991+ def test_public_client_app_receives_token_cache (self , mock_msal , tmp_path ):
992+ """token_cache= receives the SerializableTokenCache instance."""
993+ cache_path = tmp_path / "token.json"
994+ mock_cache = MagicMock ()
995+ mock_msal .SerializableTokenCache .return_value = mock_cache
996+ mock_msal .PublicClientApplication .return_value = MagicMock ()
997+
998+ auth = MsalAuth (cache_path = cache_path )
999+ auth ._build_app ()
1000+
1001+ _ , kwargs = mock_msal .PublicClientApplication .call_args
1002+ assert kwargs ["token_cache" ] is mock_cache
1003+
1004+ @patch ("flameconnect.auth.msal" )
1005+ def test_public_client_app_all_kwargs (self , mock_msal , tmp_path ):
1006+ """All keyword args passed in a single assertion."""
1007+ cache_path = tmp_path / "token.json"
1008+ mock_cache = MagicMock ()
1009+ mock_msal .SerializableTokenCache .return_value = mock_cache
1010+ mock_msal .PublicClientApplication .return_value = MagicMock ()
1011+
1012+ auth = MsalAuth (cache_path = cache_path )
1013+ auth ._build_app ()
1014+
1015+ mock_msal .PublicClientApplication .assert_called_once_with (
1016+ CLIENT_ID ,
1017+ authority = AUTHORITY ,
1018+ validate_authority = False ,
1019+ token_cache = mock_cache ,
1020+ )
1021+
1022+
1023+ # ---------------------------------------------------------------------------
1024+ # MsalAuth._save_cache -- direct call tests to kill mkdir/write/log mutants
1025+ # ---------------------------------------------------------------------------
1026+
1027+
1028+ class TestSaveCacheDirect :
1029+ """Call _save_cache() directly to kill mutants on mkdir/write_text/log."""
1030+
1031+ def test_mkdir_called_with_parents_and_exist_ok (self , tmp_path ):
1032+ """mkdir receives parents=True, exist_ok=True."""
1033+ cache_path = tmp_path / "sub" / "dir" / "token.json"
1034+ auth = MsalAuth (cache_path = cache_path )
1035+
1036+ mock_cache = MagicMock ()
1037+ mock_cache .has_state_changed = True
1038+ mock_cache .serialize .return_value = "{}"
1039+
1040+ auth ._save_cache (mock_cache )
1041+
1042+ # Parent dir was created (proves parents=True works)
1043+ assert cache_path .parent .exists ()
1044+ assert cache_path .exists ()
1045+
1046+ def test_mkdir_exist_ok_true (self , tmp_path ):
1047+ """Calling _save_cache when parent dir already exists doesn't raise."""
1048+ cache_path = tmp_path / "existing" / "token.json"
1049+ cache_path .parent .mkdir (parents = True )
1050+ auth = MsalAuth (cache_path = cache_path )
1051+
1052+ mock_cache = MagicMock ()
1053+ mock_cache .has_state_changed = True
1054+ mock_cache .serialize .return_value = '{"data": 1}'
1055+
1056+ # Should not raise (proves exist_ok=True)
1057+ auth ._save_cache (mock_cache )
1058+ assert cache_path .exists ()
1059+
1060+ def test_write_text_receives_serialized_content (self , tmp_path ):
1061+ """write_text receives cache.serialize() output (not None)."""
1062+ cache_path = tmp_path / "token.json"
1063+ auth = MsalAuth (cache_path = cache_path )
1064+
1065+ mock_cache = MagicMock ()
1066+ mock_cache .has_state_changed = True
1067+ mock_cache .serialize .return_value = '{"tokens": "abc"}'
1068+
1069+ auth ._save_cache (mock_cache )
1070+
1071+ assert cache_path .read_text () == '{"tokens": "abc"}'
1072+ mock_cache .serialize .assert_called_once ()
1073+
1074+ def test_cache_not_saved_when_unchanged (self , tmp_path ):
1075+ """When has_state_changed is False, nothing is written."""
1076+ cache_path = tmp_path / "token.json"
1077+ auth = MsalAuth (cache_path = cache_path )
1078+
1079+ mock_cache = MagicMock ()
1080+ mock_cache .has_state_changed = False
1081+
1082+ auth ._save_cache (mock_cache )
1083+
1084+ assert not cache_path .exists ()
1085+ mock_cache .serialize .assert_not_called ()
1086+
1087+ def test_log_message_format (self , tmp_path , caplog ):
1088+ """Log message uses correct format string and includes cache path."""
1089+ cache_path = tmp_path / "token.json"
1090+ auth = MsalAuth (cache_path = cache_path )
1091+
1092+ mock_cache = MagicMock ()
1093+ mock_cache .has_state_changed = True
1094+ mock_cache .serialize .return_value = "{}"
1095+
1096+ with caplog .at_level (logging .DEBUG ):
1097+ auth ._save_cache (mock_cache )
1098+
1099+ messages = [r .message for r in caplog .records ]
1100+ assert len (messages ) == 1
1101+ assert messages [0 ] == f"Token cache saved to { cache_path } "
1102+
1103+ def test_log_uses_percent_format_args (self , tmp_path , caplog ):
1104+ """Verify the logger record uses %-style args (not f-string)."""
1105+ cache_path = tmp_path / "token.json"
1106+ auth = MsalAuth (cache_path = cache_path )
1107+
1108+ mock_cache = MagicMock ()
1109+ mock_cache .has_state_changed = True
1110+ mock_cache .serialize .return_value = "{}"
1111+
1112+ with caplog .at_level (logging .DEBUG ):
1113+ auth ._save_cache (mock_cache )
1114+
1115+ record = caplog .records [0 ]
1116+ assert record .args == (cache_path ,)
1117+ assert record .msg == "Token cache saved to %s"
1118+
1119+ def test_no_log_when_cache_unchanged (self , tmp_path , caplog ):
1120+ """No log message emitted when cache hasn't changed."""
1121+ cache_path = tmp_path / "token.json"
1122+ auth = MsalAuth (cache_path = cache_path )
1123+
1124+ mock_cache = MagicMock ()
1125+ mock_cache .has_state_changed = False
1126+
1127+ with caplog .at_level (logging .DEBUG ):
1128+ auth ._save_cache (mock_cache )
1129+
1130+ assert len (caplog .records ) == 0
0 commit comments