from __future__ import annotations import os import unittest from urllib.parse import parse_qs, urlparse from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient from app import create_app from settings import get_settings class AppTestCase(unittest.TestCase): def setUp(self) -> None: self.env_patcher = patch.dict( os.environ, { "APP_BASE_URL": "http://testserver", "AUTH_SECRET_KEY": "test-auth-secret-key-that-is-long-enough", "FORGEJO_TOKEN": "", "FORGEJO_OAUTH_CLIENT_ID": "client-id", "FORGEJO_OAUTH_CLIENT_SECRET": "client-secret", }, ) self.env_patcher.start() get_settings.cache_clear() self.app = create_app() self.client = TestClient(self.app) def tearDown(self) -> None: get_settings.cache_clear() self.env_patcher.stop() def test_health(self) -> None: response = self.client.get("/health") self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"status": "ok"}) def test_prototype_payload_shape(self) -> None: payload = { "hero": {"title": "Robot U"}, "auth": { "authenticated": False, "login": None, "source": "none", "can_reply": False, "oauth_configured": True, }, "featured_courses": [], "recent_posts": [], "recent_discussions": [], "upcoming_events": [], "source_of_truth": [], } builder = AsyncMock(return_value=payload) with patch("app.build_live_prototype_payload", new=builder): response = self.client.get("/api/prototype") response_payload = response.json() self.assertEqual(response.status_code, 200) self.assertIn("hero", response_payload) self.assertIn("auth", response_payload) self.assertIn("featured_courses", response_payload) self.assertIn("recent_posts", response_payload) self.assertIn("recent_discussions", response_payload) self.assertIn("upcoming_events", response_payload) self.assertIn("source_of_truth", response_payload) self.assertEqual(builder.call_args.kwargs["forgejo_token"], None) self.assertEqual(builder.call_args.kwargs["auth_source"], "none") def test_prototype_accepts_authorization_token(self) -> None: payload = { "hero": {"title": "Robot U"}, "auth": { "authenticated": True, "login": "kacper", "source": "authorization", "can_reply": True, "oauth_configured": True, }, "featured_courses": [], "recent_posts": [], "recent_discussions": [], "upcoming_events": [], "source_of_truth": [], } builder = AsyncMock(return_value=payload) with patch("app.build_live_prototype_payload", new=builder): response = self.client.get( "/api/prototype", headers={"Authorization": "token test-token"}, ) self.assertEqual(response.status_code, 200) self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token") self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization") def test_prototype_can_use_server_token_without_user_session(self) -> None: payload = { "hero": {"title": "Robot U"}, "auth": { "authenticated": False, "login": None, "source": "server", "can_reply": False, "oauth_configured": True, }, "featured_courses": [], "recent_posts": [], "recent_discussions": [], "upcoming_events": [], "source_of_truth": [], } builder = AsyncMock(return_value=payload) get_settings.cache_clear() with ( patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}), patch("app.build_live_prototype_payload", new=builder), ): response = self.client.get("/api/prototype") self.assertEqual(response.status_code, 200) self.assertEqual(builder.call_args.kwargs["forgejo_token"], "server-token") self.assertEqual(builder.call_args.kwargs["auth_source"], "server") def test_auth_session_ignores_server_token_fallback(self) -> None: get_settings.cache_clear() with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}): response = self.client.get("/api/auth/session") payload = response.json() self.assertEqual(response.status_code, 200) self.assertEqual(payload["authenticated"], False) self.assertEqual(payload["login"], None) self.assertEqual(payload["source"], "none") self.assertEqual(payload["can_reply"], False) def test_forgejo_auth_start_redirects_to_authorization_endpoint(self) -> None: fake_client = _FakeForgejoClient() with patch("app.ForgejoClient", return_value=fake_client): response = self.client.get( "/api/auth/forgejo/start?return_to=/discussions/7", follow_redirects=False, ) location = response.headers["location"] query = parse_qs(urlparse(location).query) self.assertEqual(response.status_code, 303) self.assertTrue(location.startswith("https://aksal.cloud/login/oauth/authorize?")) self.assertEqual(query["client_id"], ["client-id"]) self.assertEqual(query["redirect_uri"], ["http://testserver/api/auth/forgejo/callback"]) self.assertEqual(query["response_type"], ["code"]) self.assertEqual(query["scope"], ["openid profile"]) self.assertEqual(query["code_challenge_method"], ["S256"]) self.assertIn("state", query) self.assertIn("code_challenge", query) def test_forgejo_auth_callback_sets_session_cookie(self) -> None: fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") with patch("app.ForgejoClient", return_value=fake_client): start_response = self.client.get( "/api/auth/forgejo/start?return_to=/discussions/7", follow_redirects=False, ) state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0] callback_response = self.client.get( f"/api/auth/forgejo/callback?code=auth-code&state={state}", follow_redirects=False, ) self.assertEqual(callback_response.status_code, 303) self.assertEqual(callback_response.headers["location"], "/discussions/7") self.assertIn("robot_u_session", callback_response.cookies) self.assertNotIn("oauth-token", callback_response.cookies["robot_u_session"]) self.assertEqual(fake_client.exchanged_code, "auth-code") session_response = self.client.get("/api/auth/session") session_payload = session_response.json() self.assertEqual(session_response.status_code, 200) self.assertEqual(session_payload["authenticated"], True) self.assertEqual(session_payload["login"], "kacper") self.assertEqual(session_payload["source"], "session") self.assertEqual(session_payload["can_reply"], True) def test_prototype_uses_signed_in_forgejo_identity_token(self) -> None: fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") with patch("app.ForgejoClient", return_value=fake_client): start_response = self.client.get( "/api/auth/forgejo/start", follow_redirects=False, ) state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0] self.client.get( f"/api/auth/forgejo/callback?code=auth-code&state={state}", follow_redirects=False, ) payload = { "hero": {"title": "Robot U"}, "auth": { "authenticated": True, "login": "kacper", "source": "session", "can_reply": True, "oauth_configured": True, }, "featured_courses": [], "recent_posts": [], "recent_discussions": [], "upcoming_events": [], "source_of_truth": [], } builder = AsyncMock(return_value=payload) with patch("app.build_live_prototype_payload", new=builder): response = self.client.get("/api/prototype") self.assertEqual(response.status_code, 200) self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token") self.assertEqual(builder.call_args.kwargs["auth_source"], "session") self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper") def test_encrypted_session_cookie_survives_new_app_instance(self) -> None: fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") with patch("app.ForgejoClient", return_value=fake_client): start_response = self.client.get( "/api/auth/forgejo/start", follow_redirects=False, ) state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0] callback_response = self.client.get( f"/api/auth/forgejo/callback?code=auth-code&state={state}", follow_redirects=False, ) fresh_client = TestClient(create_app()) fresh_client.cookies.set("robot_u_session", callback_response.cookies["robot_u_session"]) session_response = fresh_client.get("/api/auth/session") self.assertEqual(session_response.status_code, 200) self.assertEqual(session_response.json()["authenticated"], True) self.assertEqual(session_response.json()["login"], "kacper") self.assertEqual(session_response.json()["source"], "session") def test_create_discussion_reply(self) -> None: fake_client = _FakeForgejoClient( comment={ "id": 123, "body": "Thanks, this helped.", "created_at": "2026-04-11T12:00:00Z", "html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123", "user": { "login": "Kacper", "avatar_url": "https://aksal.cloud/avatar.png", }, }, ) with patch("app.ForgejoClient", return_value=fake_client) as client_factory: response = self.client.post( "/api/discussions/replies", json={ "owner": "Robot-U", "repo": "RobotClass", "number": 2, "body": "Thanks, this helped.", }, headers={"Authorization": "token test-token"}, ) payload = response.json() self.assertEqual(response.status_code, 200) self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token") self.assertEqual( fake_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.") ) self.assertEqual(payload["id"], 123) self.assertEqual(payload["author"], "Kacper") self.assertEqual(payload["body"], "Thanks, this helped.") def test_create_discussion_reply_uses_signed_in_identity(self) -> None: sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") with patch("app.ForgejoClient", return_value=sign_in_client): start_response = self.client.get( "/api/auth/forgejo/start", follow_redirects=False, ) state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0] self.client.get( f"/api/auth/forgejo/callback?code=auth-code&state={state}", follow_redirects=False, ) reply_client = _FakeForgejoClient( comment={ "id": 123, "body": "Thanks, this helped.", "created_at": "2026-04-11T12:00:00Z", "html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123", "user": {"login": "Kacper", "avatar_url": ""}, }, ) with patch("app.ForgejoClient", return_value=reply_client) as client_factory: response = self.client.post( "/api/discussions/replies", json={ "owner": "Robot-U", "repo": "RobotClass", "number": 2, "body": "Thanks, this helped.", }, ) self.assertEqual(response.status_code, 200) self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token") self.assertEqual( reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.") ) def test_create_discussion_reply_rejects_private_repo(self) -> None: fake_client = _FakeForgejoClient(repo_private=True) with patch("app.ForgejoClient", return_value=fake_client): response = self.client.post( "/api/discussions/replies", json={ "owner": "Robot-U", "repo": "PrivateRobotClass", "number": 2, "body": "Thanks, this helped.", }, headers={"Authorization": "token test-token"}, ) self.assertEqual(response.status_code, 403) self.assertIsNone(fake_client.created_comment) def test_create_discussion_reply_rejects_server_token_fallback(self) -> None: get_settings.cache_clear() with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}): response = self.client.post( "/api/discussions/replies", json={ "owner": "Robot-U", "repo": "RobotClass", "number": 2, "body": "Thanks, this helped.", }, ) self.assertEqual(response.status_code, 401) class _FakeForgejoClient: def __init__( self, comment: dict[str, object] | None = None, user: dict[str, object] | None = None, access_token: str = "test-oauth-token", repo_private: bool = False, ) -> None: self._comment = comment self._user = user or {"login": "test-user"} self._access_token = access_token self._repo_private = repo_private self.created_comment: tuple[str, str, int, str] | None = None self.exchanged_code: str | None = None async def __aenter__(self) -> _FakeForgejoClient: return self async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None: return None async def create_issue_comment( self, owner: str, repo: str, issue_number: int, body: str, ) -> dict[str, object]: self.created_comment = (owner, repo, issue_number, body) if self._comment is None: raise AssertionError("Fake comment was not configured.") return self._comment async def fetch_current_user(self) -> dict[str, object]: return self._user async def fetch_repository(self, owner: str, repo: str) -> dict[str, object]: return { "owner": {"login": owner}, "name": repo, "full_name": f"{owner}/{repo}", "private": self._repo_private, } async def fetch_openid_configuration(self) -> dict[str, object]: return { "authorization_endpoint": "https://aksal.cloud/login/oauth/authorize", "token_endpoint": "https://aksal.cloud/login/oauth/access_token", "userinfo_endpoint": "https://aksal.cloud/login/oauth/userinfo", } async def exchange_oauth_code( self, *, token_endpoint: str, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str, ) -> dict[str, object]: self.exchanged_code = code self.exchanged_token_request = { "token_endpoint": token_endpoint, "client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri, "code_verifier": code_verifier, } return {"access_token": self._access_token} async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, object]: self.fetched_userinfo = { "userinfo_endpoint": userinfo_endpoint, "access_token": access_token, } return { "preferred_username": self._user["login"], "picture": self._user.get("avatar_url", ""), "email": self._user.get("email", ""), } if __name__ == "__main__": unittest.main()