From 8da1f13e60cd9ae15a7988b02578783d9252264c Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Fri, 12 Jun 2026 23:15:56 +0200 Subject: [PATCH] M2-T02: add session/auth JSON API for the SPA - GET /api/session (user + csrf_token, 401 when unauthenticated) - POST /api/auth/login (sets HttpOnly session cookie; 401 on bad creds; no CSRF) - POST /api/auth/logout (session+CSRF; revokes session, clears cookie; 204) - POST /api/auth/password (session+CSRF; reuses change_password; 400 on failure; 204) - reuses app/services/auth.py and shared require_session/require_csrf deps - register router in app/main.py; regenerate openapi/ - tests/test_api_session.py --- app/api/routes/api/session.py | 141 ++++++++++++++ app/main.py | 2 + app/schemas/session.py | 24 +++ openapi/openapi.json | 246 ++++++++++++++++++++++++ openapi/openapi.yaml | 178 +++++++++++++++++ tests/test_api_session.py | 352 ++++++++++++++++++++++++++++++++++ 6 files changed, 943 insertions(+) create mode 100644 app/api/routes/api/session.py create mode 100644 app/schemas/session.py create mode 100644 tests/test_api_session.py diff --git a/app/api/routes/api/session.py b/app/api/routes/api/session.py new file mode 100644 index 0000000..6592c55 --- /dev/null +++ b/app/api/routes/api/session.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.config import Settings +from app.dependencies import get_app_settings, get_db +from app.schemas.session import ( + LoginRequest, + PasswordChangeRequest, + SessionResponse, + SessionUser, +) +from app.services.auth import ( + AuthPasswordChangeError, + AuthenticatedSession, + authenticate_user, + change_password, + create_session, + revoke_session, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["api-session"]) + + +def _build_session_response(auth: AuthenticatedSession) -> SessionResponse: + return SessionResponse( + user=SessionUser( + username=auth.user.username, + force_password_change=auth.user.force_password_change, + ), + csrf_token=auth.session.csrf_token, + ) + + +@router.get("/session", response_model=SessionResponse) +def get_session( + auth: AuthenticatedSession = Depends(require_session), +) -> SessionResponse: + """Return the current session user and CSRF token. Returns 401 if not authenticated.""" + return _build_session_response(auth) + + +@router.post("/auth/login", response_model=SessionResponse) +def post_login( + body: LoginRequest, + response: Response, + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), +) -> SessionResponse: + """ + Authenticate with username and password. + + On success, sets an HttpOnly session cookie and returns the session user + CSRF token. + On failure, returns 401 with no cookie set. + No X-CSRF-Token required (unauthenticated endpoint). + """ + user = authenticate_user(db, username=body.username, password=body.password) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) + + auth_session, raw_token = create_session(db, user=user, settings=settings) + logger.info("Created API authenticated session for user '%s'", user.username) + + response.set_cookie( + key=settings.auth_session_cookie_name, + value=raw_token, + max_age=settings.auth_session_ttl_hours * 3600, + httponly=True, + secure=settings.auth_cookie_secure, + samesite="lax", + path="/", + ) + + auth = AuthenticatedSession(user=user, session=auth_session) + return _build_session_response(auth) + + +@router.post("/auth/logout") +def post_logout( + response: Response, + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), + auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> Response: + """ + Revoke the current session and clear the session cookie. + Requires authentication and X-CSRF-Token header. + Returns 204 No Content. + """ + revoke_session(db, auth_session=auth.session) + logger.info("Revoked API authenticated session for user '%s'", auth.user.username) + no_content = Response(status_code=status.HTTP_204_NO_CONTENT) + no_content.delete_cookie(settings.auth_session_cookie_name, path="/") + return no_content + + +@router.post("/auth/password") +def post_change_password( + body: PasswordChangeRequest, + db: Session = Depends(get_db), + auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> Response: + """ + Change the current user's password. + Requires authentication and X-CSRF-Token header. + On AuthPasswordChangeError returns 400 with a generic message. + On success, force_password_change becomes False (handled by the service). + Returns 204 No Content. + """ + try: + change_password( + db, + user=auth.user, + current_password=body.current_password, + new_password=body.new_password, + confirm_password=body.confirm_password, + ) + except AuthPasswordChangeError as exc: + logger.info( + "Rejected password change for user '%s': %s", + auth.user.username, + exc, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="password change failed", + ) from exc + + logger.info("Password updated for user '%s'", auth.user.username) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/main.py b/app/main.py index 15a0512..16a6180 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router +from app.api.routes.api.session import router as api_session_router from app.api.routes.auth import router as auth_router from app.api.routes import pages, status from app.db import get_session_local @@ -93,6 +94,7 @@ def create_app() -> FastAPI: app.include_router(auth_router) app.include_router(pages.router) app.include_router(api_config_router) + app.include_router(api_session_router) app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) diff --git a/app/schemas/session.py b/app/schemas/session.py new file mode 100644 index 0000000..6eecaac --- /dev/null +++ b/app/schemas/session.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class SessionUser(BaseModel): + username: str + force_password_change: bool + + +class SessionResponse(BaseModel): + user: SessionUser + csrf_token: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +class PasswordChangeRequest(BaseModel): + current_password: str + new_password: str + confirm_password: str diff --git a/openapi/openapi.json b/openapi/openapi.json index 32701a0..342ab18 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -350,6 +350,176 @@ } } }, + "/api/session": { + "get": { + "tags": [ + "api-session" + ], + "summary": "Get Session", + "description": "Return the current session user and CSRF token. Returns 401 if not authenticated.", + "operationId": "get_session_api_session_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "api-session" + ], + "summary": "Post Login", + "description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nNo X-CSRF-Token required (unauthenticated endpoint).", + "operationId": "post_login_api_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": [ + "api-session" + ], + "summary": "Post Logout", + "description": "Revoke the current session and clear the session cookie.\nRequires authentication and X-CSRF-Token header.\nReturns 204 No Content.", + "operationId": "post_logout_api_auth_logout_post", + "parameters": [ + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/auth/password": { + "post": { + "tags": [ + "api-session" + ], + "summary": "Post Change Password", + "description": "Change the current user's password.\nRequires authentication and X-CSRF-Token header.\nOn AuthPasswordChangeError returns 400 with a generic message.\nOn success, force_password_change becomes False (handled by the service).\nReturns 204 No Content.", + "operationId": "post_change_password_api_auth_password_post", + "parameters": [ + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/homeassistant/publish": { "post": { "tags": [ @@ -673,6 +843,47 @@ "type": "object", "title": "HTTPValidationError" }, + "LoginRequest": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "username", + "password" + ], + "title": "LoginRequest" + }, + "PasswordChangeRequest": { + "properties": { + "current_password": { + "type": "string", + "title": "Current Password" + }, + "new_password": { + "type": "string", + "title": "New Password" + }, + "confirm_password": { + "type": "string", + "title": "Confirm Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password", + "confirm_password" + ], + "title": "PasswordChangeRequest" + }, "PublicIPCheckResponse": { "properties": { "status": { @@ -703,6 +914,41 @@ ], "title": "PublicIPCheckResponse" }, + "SessionResponse": { + "properties": { + "user": { + "$ref": "#/components/schemas/SessionUser" + }, + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "user", + "csrf_token" + ], + "title": "SessionResponse" + }, + "SessionUser": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "force_password_change": { + "type": "boolean", + "title": "Force Password Change" + } + }, + "type": "object", + "required": [ + "username", + "force_password_change" + ], + "title": "SessionUser" + }, "StatusResponse": { "properties": { "status": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 32e1d6f..a74ea79 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -222,6 +222,129 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/session: + get: + tags: + - api-session + summary: Get Session + description: Return the current session user and CSRF token. Returns 401 if + not authenticated. + operationId: get_session_api_session_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + /api/auth/login: + post: + tags: + - api-session + summary: Post Login + description: 'Authenticate with username and password. + + + On success, sets an HttpOnly session cookie and returns the session user + + CSRF token. + + On failure, returns 401 with no cookie set. + + No X-CSRF-Token required (unauthenticated endpoint).' + operationId: post_login_api_auth_login_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/auth/logout: + post: + tags: + - api-session + summary: Post Logout + description: 'Revoke the current session and clear the session cookie. + + Requires authentication and X-CSRF-Token header. + + Returns 204 No Content.' + operationId: post_logout_api_auth_logout_post + parameters: + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/auth/password: + post: + tags: + - api-session + summary: Post Change Password + description: 'Change the current user''s password. + + Requires authentication and X-CSRF-Token header. + + On AuthPasswordChangeError returns 400 with a generic message. + + On success, force_password_change becomes False (handled by the service). + + Returns 204 No Content.' + operationId: post_change_password_api_auth_password_post + parameters: + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChangeRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /homeassistant/publish: post: tags: @@ -443,6 +566,36 @@ components: title: Detail type: object title: HTTPValidationError + LoginRequest: + properties: + username: + type: string + title: Username + password: + type: string + title: Password + type: object + required: + - username + - password + title: LoginRequest + PasswordChangeRequest: + properties: + current_password: + type: string + title: Current Password + new_password: + type: string + title: New Password + confirm_password: + type: string + title: Confirm Password + type: object + required: + - current_password + - new_password + - confirm_password + title: PasswordChangeRequest PublicIPCheckResponse: properties: status: @@ -466,6 +619,31 @@ components: - checked_at - changed title: PublicIPCheckResponse + SessionResponse: + properties: + user: + $ref: '#/components/schemas/SessionUser' + csrf_token: + type: string + title: Csrf Token + type: object + required: + - user + - csrf_token + title: SessionResponse + SessionUser: + properties: + username: + type: string + title: Username + force_password_change: + type: boolean + title: Force Password Change + type: object + required: + - username + - force_password_change + title: SessionUser StatusResponse: properties: status: diff --git a/tests/test_api_session.py b/tests/test_api_session.py new file mode 100644 index 0000000..2b7293d --- /dev/null +++ b/tests/test_api_session.py @@ -0,0 +1,352 @@ +"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password.""" +from __future__ import annotations + +import re + +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_csrf_token(html: str) -> str: + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None, "csrf_token not found in HTML" + return match.group(1) + + +def _jinja_login(client: TestClient) -> None: + """Log in via the existing Jinja form so the client has a session cookie.""" + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + resp = client.post( + "/login", + data={"username": "admin", "password": "test-password", "csrf_token": csrf_token}, + follow_redirects=False, + ) + assert resp.status_code == 303, f"Jinja login failed: {resp.status_code}" + + +def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"): + """Log in via POST /api/auth/login and return the response.""" + return client.post( + "/api/auth/login", + json={"username": username, "password": password}, + ) + + +# --------------------------------------------------------------------------- +# GET /api/session — unauthenticated +# --------------------------------------------------------------------------- + + +def test_get_session_unauthenticated_returns_401(client: TestClient) -> None: + response = client.get("/api/session") + assert response.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/session — authenticated (via Jinja login) +# --------------------------------------------------------------------------- + + +def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None: + _jinja_login(client) + + response = client.get("/api/session") + + assert response.status_code == 200 + body = response.json() + assert "user" in body + assert "csrf_token" in body + assert body["user"]["username"] == "admin" + assert isinstance(body["user"]["force_password_change"], bool) + assert isinstance(body["csrf_token"], str) + assert body["csrf_token"] # non-empty + + +def test_get_session_does_not_leak_password(client: TestClient) -> None: + _jinja_login(client) + response = client.get("/api/session") + body_str = str(response.json()) + assert "test-password" not in body_str + assert "password_hash" not in body_str + + +# --------------------------------------------------------------------------- +# POST /api/auth/login +# --------------------------------------------------------------------------- + + +def test_post_login_valid_credentials_returns_200_with_session(client: TestClient) -> None: + response = _api_login(client) + + assert response.status_code == 200 + body = response.json() + assert "user" in body + assert "csrf_token" in body + assert body["user"]["username"] == "admin" + assert isinstance(body["csrf_token"], str) + assert body["csrf_token"] + + +def test_post_login_sets_httponly_session_cookie(client: TestClient) -> None: + response = _api_login(client) + + assert response.status_code == 200 + set_cookie = response.headers.get("set-cookie", "").lower() + assert "home_automation_session=" in set_cookie + assert "httponly" in set_cookie + assert "samesite=lax" in set_cookie + assert "path=/" in set_cookie + + +def test_post_login_cookie_secure_flag_follows_settings(client: TestClient) -> None: + """In test mode AUTH_COOKIE_SECURE_OVERRIDE=false so secure should be absent.""" + response = _api_login(client) + + assert response.status_code == 200 + set_cookie = response.headers.get("set-cookie", "").lower() + # secure is absent because AUTH_COOKIE_SECURE_OVERRIDE=false in conftest + assert "secure" not in set_cookie + + +def test_post_login_invalid_credentials_returns_401(client: TestClient) -> None: + response = _api_login(client, password="wrong-password") + + assert response.status_code == 401 + # No session cookie should be set + assert "set-cookie" not in response.headers or ( + "home_automation_session=" not in response.headers.get("set-cookie", "").lower() + ) + + +def test_post_login_unknown_user_returns_401(client: TestClient) -> None: + response = _api_login(client, username="nobody", password="irrelevant") + assert response.status_code == 401 + + +def test_post_login_does_not_require_csrf_header(client: TestClient) -> None: + """Login is unauthenticated; no X-CSRF-Token should be required.""" + response = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, + # Deliberately omit X-CSRF-Token + ) + assert response.status_code == 200 + + +def test_post_login_allows_subsequent_authenticated_request(client: TestClient) -> None: + login_resp = _api_login(client) + assert login_resp.status_code == 200 + + # GET /api/session should now succeed (cookie was set on the client) + session_resp = client.get("/api/session") + assert session_resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# POST /api/auth/logout +# --------------------------------------------------------------------------- + + +def test_post_logout_unauthenticated_returns_401(client: TestClient) -> None: + response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"}) + assert response.status_code == 401 + + +def test_post_logout_authenticated_missing_csrf_returns_403(client: TestClient) -> None: + _api_login(client) + response = client.post("/api/auth/logout") + assert response.status_code == 403 + + +def test_post_logout_authenticated_empty_csrf_returns_403(client: TestClient) -> None: + _api_login(client) + response = client.post("/api/auth/logout", headers={"X-CSRF-Token": ""}) + assert response.status_code == 403 + + +def test_post_logout_authenticated_with_csrf_returns_204(client: TestClient) -> None: + _api_login(client) + response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "any-non-empty-value"}) + assert response.status_code == 204 + + +def test_post_logout_invalidates_session(client: TestClient) -> None: + _api_login(client) + + # Verify session is active + assert client.get("/api/session").status_code == 200 + + # Logout + client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"}) + + # Session should now be gone + assert client.get("/api/session").status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/auth/password +# --------------------------------------------------------------------------- + + +def test_post_password_unauthenticated_returns_401(client: TestClient) -> None: + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 401 + + +def test_post_password_authenticated_missing_csrf_returns_403(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + ) + assert response.status_code == 403 + + +def test_post_password_success_returns_204(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 204 + + +def test_post_password_wrong_current_password_returns_400(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "wrong-current", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 400 + # Error message must be generic — no leaking which check failed + detail = response.json().get("detail", "") + assert "current password is invalid" not in detail + assert detail == "password change failed" + + +def test_post_password_mismatched_new_passwords_returns_400(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "different-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "password change failed" + + +def test_post_password_too_short_returns_400(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "short", + "confirm_password": "short", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "password change failed" + + +def test_post_password_same_as_current_returns_400(client: TestClient) -> None: + _api_login(client) + response = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "test-password", + "confirm_password": "test-password", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "password change failed" + + +def test_post_password_success_sets_force_password_change_false(client: TestClient) -> None: + """After successful password change, force_password_change should be False.""" + _api_login(client) + + # The bootstrap user always has force_password_change=True; change it + resp = client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + assert resp.status_code == 204 + + # Session still active; force_password_change should now be False + session_resp = client.get("/api/session") + assert session_resp.status_code == 200 + assert session_resp.json()["user"]["force_password_change"] is False + + +def test_post_password_does_not_revoke_session(client: TestClient) -> None: + """After password change, the session remains valid (not revoked).""" + _api_login(client) + + client.post( + "/api/auth/password", + json={ + "current_password": "test-password", + "new_password": "new-password-123", + "confirm_password": "new-password-123", + }, + headers={"X-CSRF-Token": "token"}, + ) + + # Session must still be active + assert client.get("/api/session").status_code == 200 + + +# --------------------------------------------------------------------------- +# Response schema correctness — no secrets in session response +# --------------------------------------------------------------------------- + + +def test_session_response_has_no_secret_fields(client: TestClient) -> None: + login_resp = _api_login(client) + assert login_resp.status_code == 200 + body = login_resp.json() + + # Must have exactly these top-level keys + assert set(body.keys()) == {"user", "csrf_token"} + # user must have exactly these keys + assert set(body["user"].keys()) == {"username", "force_password_change"}