robot-u-site/tests/test_app.py

413 lines
16 KiB
Python

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",
"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.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_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()