from dataclasses import replace from fastapi.testclient import TestClient from codexpi.main import create_app def test_login_uses_http_only_strict_cookie(app_env): client, *_ = app_env response = client.post("/api/auth/login", json={"username": "admin", "password": "a-very-long-test-password"}) assert response.status_code == 200 cookie = response.headers["set-cookie"].lower() assert "httponly" in cookie assert "samesite=strict" in cookie assert "a-very-long-test-password" not in cookie def test_production_cookie_is_secure(app_env, tmp_path): _, app, *_ = app_env data = tmp_path / "secure-data" data.mkdir() settings = replace(app.state.settings, data_dir=data, database_path=data / "secure.db", cookie_secure=True) secure_app = create_app(settings) with TestClient(secure_app) as client: secure_app.state.auth.create_user("secureadmin", "another-long-test-password") response = client.post("/api/auth/login", json={"username": "secureadmin", "password": "another-long-test-password"}) assert "secure" in response.headers["set-cookie"].lower() def test_wrong_password_is_rejected(app_env): client, *_ = app_env response = client.post("/api/auth/login", json={"username": "admin", "password": "this-password-is-wrong"}) assert response.status_code == 401 log = (app_env[1].state.settings.data_dir / "security.log").read_text(encoding="utf-8") assert '"event":"auth.login_failed"' in log assert "this-password-is-wrong" not in log def test_logout_revokes_and_logs_session(logged_in): client, app, _, _, headers = logged_in assert client.post("/api/auth/logout", headers=headers).status_code == 200 assert client.get("/api/auth/me").status_code == 401 assert '"event":"session.deleted"' in (app.state.settings.data_dir / "security.log").read_text(encoding="utf-8") def test_csrf_required_for_mutations(logged_in): client, _, projects, _, _ = logged_in project = projects / "demo" project.mkdir() response = client.post("/api/projects", json={"name": "demo", "path": str(project)}) assert response.status_code == 403 def test_security_headers(app_env): client, *_ = app_env response = client.get("/api/health") assert response.headers["x-frame-options"] == "DENY" assert response.headers["x-content-type-options"] == "nosniff" assert "frame-ancestors 'none'" in response.headers["content-security-policy"] def test_admin_manages_accounts_and_users_only_see_their_projects(logged_in): client, _, projects, _, admin_headers = logged_in admin_root = projects / "admin-project" member_root = projects / "member-project" admin_root.mkdir() member_root.mkdir() admin_project = client.post( "/api/projects", headers=admin_headers, json={"name": "Admin", "path": str(admin_root)}, ).json() created_user = client.post( "/api/admin/users", headers=admin_headers, json={"username": "member", "password": "member-password-long", "is_admin": False}, ) assert created_user.status_code == 201 assert created_user.json()["is_admin"] in {False, 0} assert client.post("/api/auth/logout", headers=admin_headers).status_code == 200 login = client.post("/api/auth/login", json={"username": "member", "password": "member-password-long"}) member_headers = {"X-CSRF-Token": login.json()["csrf_token"]} assert login.status_code == 200 and login.json()["is_admin"] is False assert client.get("/api/admin/users").status_code == 403 assert admin_project["id"] not in {item["id"] for item in client.get("/api/projects").json()} member_project = client.post( "/api/projects", headers=member_headers, json={"name": "Member", "path": str(member_root)}, ) assert member_project.status_code == 201 assert {item["id"] for item in client.get("/api/projects").json()} == {member_project.json()["id"]} assert client.get(f"/api/projects/{admin_project['id']}/git").status_code == 404