"""Offline tests for swiftly_auth — Firebase REST anon-signUp + cache. All tests run with ``requests.get``/``requests.post`` patched. No live network. The live integration check belongs in ``scripts/spike_swiftly_ingest.py --confirm-live``. """ from __future__ import annotations import base64 import json import sys import time from pathlib import Path from unittest.mock import MagicMock, patch import pytest import requests BACKEND_DIR = Path(__file__).resolve().parent.parent if str(BACKEND_DIR) not in sys.path: sys.path.insert(0, str(BACKEND_DIR)) from app.services import swiftly_auth # noqa: E402 from app.services.swiftly_auth import ( # noqa: E402 SwiftlyAuthMintError, get_token, ) API_KEY = "AIzaSy-test-key" EXPECTED_ISS = "https://securetoken.google.com/swiftly-lu-prod" @pytest.fixture(autouse=True) def _clear_cache(): swiftly_auth._reset_cache_for_tests() yield swiftly_auth._reset_cache_for_tests() def _b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def _make_jwt(*, exp: float, iss: str = EXPECTED_ISS) -> str: header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) payload = _b64url( json.dumps({"iss": iss, "aud": "swiftly-lu-prod", "exp": int(exp)}).encode() ) sig = _b64url(b"signature-bytes") return f"{header}.{payload}.{sig}" def _config_response() -> MagicMock: resp = MagicMock(spec=requests.Response) resp.status_code = 200 resp.json.return_value = {"firebaseApiKey": API_KEY} return resp def _signup_response(*, id_token: str, status_code: int = 200) -> MagicMock: resp = MagicMock(spec=requests.Response) resp.status_code = status_code resp.text = "ok" if status_code == 200 else "boom" if status_code == 200: resp.json.return_value = {"idToken": id_token} else: resp.json.side_effect = ValueError("not json") return resp def test_get_token_mints_fresh_when_cache_empty(): """First call fetches config.json, posts to Firebase, returns idToken.""" fresh_exp = time.time() + 3600 token = _make_jwt(exp=fresh_exp) with patch.object( swiftly_auth.requests, "get", return_value=_config_response() ) as mget, patch.object( swiftly_auth.requests, "post", return_value=_signup_response(id_token=token), ) as mpost: result = get_token() assert result == token mget.assert_called_once() config_url = mget.call_args.args[0] assert config_url == "https://luckysupermarkets.com/config.json" mpost.assert_called_once() post_url = mpost.call_args.args[0] assert post_url == "https://identitytoolkit.googleapis.com/v1/accounts:signUp" assert mpost.call_args.kwargs["params"] == {"key": API_KEY} headers = mpost.call_args.kwargs["headers"] assert headers["Origin"] == "https://luckysupermarkets.com" assert headers["Referer"] == "https://luckysupermarkets.com/" def test_get_token_returns_cached_without_reminting(): """Second call within the refresh window does NOT re-hit Firebase.""" fresh_exp = time.time() + 3600 # well above the 300s refresh margin token = _make_jwt(exp=fresh_exp) with patch.object( swiftly_auth.requests, "get", return_value=_config_response() ), patch.object( swiftly_auth.requests, "post", return_value=_signup_response(id_token=token), ) as mpost: first = get_token() second = get_token() assert first == second == token assert mpost.call_count == 1, "expected exactly one signUp call across two get_token() calls" def test_get_token_remints_when_cached_token_near_expiry(): """A cached token within the refresh-margin window triggers a re-mint.""" near_expiry = time.time() + 120 # < 300s margin → must refresh fresh_exp = time.time() + 3600 stale_token = _make_jwt(exp=near_expiry) fresh_token = _make_jwt(exp=fresh_exp) swiftly_auth._state["api_key"] = API_KEY # skip the config fetch swiftly_auth._state["token"] = stale_token swiftly_auth._state["exp"] = near_expiry with patch.object( swiftly_auth.requests, "post", return_value=_signup_response(id_token=fresh_token), ) as mpost: result = get_token() assert result == fresh_token mpost.assert_called_once() def test_mint_raises_on_firebase_non_200(): """Firebase 403/400/etc. surfaces as SwiftlyAuthMintError with status detail.""" with patch.object( swiftly_auth.requests, "get", return_value=_config_response() ), patch.object( swiftly_auth.requests, "post", return_value=_signup_response(id_token="ignored", status_code=403), ): with pytest.raises(SwiftlyAuthMintError) as excinfo: get_token() assert "403" in str(excinfo.value)