File size: 3,380 Bytes
67af2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
"""Error-handling regression tests.



The previous global handler returned ``str(exc)`` in the response body, which for a

SQLAlchemy failure includes the connection URL and its password.

"""

from __future__ import annotations

import pytest
from fastapi.testclient import TestClient

from app.core.config import Settings
from app.db.deps import get_db
from app.main import create_app

LEAKY_MESSAGE = (
    "(psycopg2.OperationalError) connection to server at "
    "postgresql://admin:hunter2@db.example.com:5432/facevision failed"
)


def _app_with_exploding_db(environment: str) -> TestClient:
    settings = Settings(
        environment=environment,
        database_url="sqlite://",
        FACEVISION_INGEST_KEY="ingest",
        log_level="CRITICAL",
    )
    app = create_app(settings)

    class ExplodingSession:
        def execute(self, *_args, **_kwargs):
            raise RuntimeError(LEAKY_MESSAGE)

        def close(self) -> None:
            pass

    def exploding_db():
        yield ExplodingSession()

    app.dependency_overrides[get_db] = exploding_db
    return TestClient(app, raise_server_exceptions=False)


class TestInternalErrorsDoNotLeak:
    def test_production_body_omits_the_exception_text(self):
        client = _app_with_exploding_db("production")
        response = client.get("/api/logs", headers={"X-Session-Id": "error-test-session-01"})
        assert response.status_code == 500
        body = response.text
        assert "hunter2" not in body
        assert "postgresql://" not in body
        assert "Traceback" not in body

    def test_production_body_carries_a_request_id(self):
        client = _app_with_exploding_db("production")
        body = client.get("/api/logs", headers={"X-Session-Id": "error-test-session-01"}).json()
        assert body["message"] == "Internal Server Error"
        assert body["request_id"]

    def test_development_includes_detail_for_debugging(self):
        client = _app_with_exploding_db("development")
        body = client.get("/api/logs", headers={"X-Session-Id": "error-test-session-01"}).json()
        assert "detail" in body


class TestValidationErrors:
    def test_validation_failure_reports_field_errors(self, client, ingest_headers):
        response = client.post(
            "/api/logs",
            json={"dominant_emotion": "Nope", "duration_seconds": "abc"},
            headers=ingest_headers,
        )
        assert response.status_code == 422
        body = response.json()
        assert body["message"] == "Validation failed"
        assert body["errors"]


class TestRequestId:
    def test_successful_responses_carry_a_request_id_header(self, client):
        response = client.get("/api/health")
        assert response.headers.get("X-Request-ID")


class TestSecurityHeaders:
    @pytest.mark.parametrize(

        ("header", "value"),

        [

            ("Cross-Origin-Opener-Policy", "same-origin"),

            ("Cross-Origin-Embedder-Policy", "require-corp"),

            ("X-Content-Type-Options", "nosniff"),

        ],

    )
    def test_isolation_headers_are_present(self, client, header, value):
        """next.config.ts declares these but they are inert under output: 'export'."""
        assert client.get("/api/health").headers.get(header) == value