Skip to content

Commit eeeab66

Browse files
test: add TokenAuth, _build_app, and _save_cache mutant-killing tests
Cherry-pick auth tests from Copilot PR #76 that complement our existing mutation testing coverage with direct unit tests for auth internals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f97c651 commit eeeab66

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

tests/test_auth.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)