From 8328b541c83b9a11ba39191eac35b128822f0bb1 Mon Sep 17 00:00:00 2001 From: manavgup Date: Mon, 30 Mar 2026 14:48:00 -0400 Subject: [PATCH] Phase 2: Logging, middleware, health checks, and setup_app() - logging/config.py: LogConfig dataclass - logging/masking.py: Recursive sensitive data masking (depth-limited, non-mutating) - logging/structured_logger.py: StructuredLogger + correlation_id contextvar - middleware/correlation_id.py: UUID per request, X-Correlation-ID header - middleware/request_logging.py: method/path/status/duration logging - middleware/security_headers.py: HSTS, X-Frame-Options, etc. - health/endpoints.py: GET /health + GET /health/detailed - settings/config.py: FastStackConfig dataclass (toggles for all features) - setup.py: setup_app() one-call registration with per-feature toggles 31 new tests (100 total) covering: - Sensitive data masking (nested dicts, depth limit, case-insensitive) - Correlation ID generation and propagation - Security headers presence and non-override behavior - Health endpoints - setup_app() with all defaults and with features individually disabled - CORS enable/disable Part of Phase 2 in #1 Co-Authored-By: Claude Opus 4.6 (1M context) --- faststack_core/health/endpoints.py | 23 ++++ faststack_core/logging/config.py | 21 +++ faststack_core/logging/masking.py | 44 ++++++ faststack_core/logging/structured_logger.py | 33 +++++ faststack_core/middleware/correlation_id.py | 27 ++++ faststack_core/middleware/request_logging.py | 31 +++++ faststack_core/middleware/security_headers.py | 24 ++++ faststack_core/settings/config.py | 39 ++++++ faststack_core/setup.py | 58 ++++++++ tests/test_core/test_health.py | 30 ++++ tests/test_core/test_logging.py | 128 ++++++++++++++++++ tests/test_core/test_middleware.py | 92 +++++++++++++ tests/test_core/test_setup.py | 125 +++++++++++++++++ 13 files changed, 675 insertions(+) create mode 100644 faststack_core/health/endpoints.py create mode 100644 faststack_core/logging/config.py create mode 100644 faststack_core/logging/masking.py create mode 100644 faststack_core/logging/structured_logger.py create mode 100644 faststack_core/middleware/correlation_id.py create mode 100644 faststack_core/middleware/request_logging.py create mode 100644 faststack_core/middleware/security_headers.py create mode 100644 faststack_core/settings/config.py create mode 100644 faststack_core/setup.py create mode 100644 tests/test_core/test_health.py create mode 100644 tests/test_core/test_logging.py create mode 100644 tests/test_core/test_middleware.py create mode 100644 tests/test_core/test_setup.py diff --git a/faststack_core/health/endpoints.py b/faststack_core/health/endpoints.py new file mode 100644 index 0000000..998502c --- /dev/null +++ b/faststack_core/health/endpoints.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + + +def create_health_router(app_version: str = "0.1.0") -> APIRouter: + """Create a health check router. + + GET /health — simple liveness check + GET /health/detailed — includes version (DB check requires session dependency override) + """ + router = APIRouter(tags=["health"]) + + @router.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @router.get("/health/detailed") + async def health_detailed() -> dict[str, str]: + return { + "status": "ok", + "version": app_version, + } + + return router diff --git a/faststack_core/logging/config.py b/faststack_core/logging/config.py new file mode 100644 index 0000000..bb3f5d9 --- /dev/null +++ b/faststack_core/logging/config.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + + +@dataclass +class LogConfig: + """Logging configuration.""" + + level: str = "INFO" + format: str = "json" # "json" or "text" + app_name: str = "faststack" + # Fields that should always be masked in log output + sensitive_patterns: list[str] = field( + default_factory=lambda: [ + "password", + "secret", + "token", + "api_key", + "authorization", + "credit_card", + ] + ) diff --git a/faststack_core/logging/masking.py b/faststack_core/logging/masking.py new file mode 100644 index 0000000..78400a9 --- /dev/null +++ b/faststack_core/logging/masking.py @@ -0,0 +1,44 @@ +from typing import Any + +DEFAULT_SENSITIVE_PATTERNS = [ + "password", + "secret", + "token", + "api_key", + "authorization", + "credit_card", +] +MASK_VALUE = "***MASKED***" + + +def mask_sensitive_data( + data: Any, + sensitive_patterns: list[str] | None = None, + max_depth: int = 5, +) -> Any: + """Recursively mask values for keys matching sensitive patterns. + + - Works on dicts, lists, and nested combinations + - Depth-limited to prevent performance issues on large payloads + - Does NOT mutate the input — returns a masked copy + - Key matching is case-insensitive substring match + """ + if max_depth <= 0: + return data + + patterns = sensitive_patterns if sensitive_patterns is not None else DEFAULT_SENSITIVE_PATTERNS + + if isinstance(data, dict): + masked = {} + for key, value in data.items(): + key_lower = key.lower() if isinstance(key, str) else str(key).lower() + if any(pattern.lower() in key_lower for pattern in patterns): + masked[key] = MASK_VALUE + else: + masked[key] = mask_sensitive_data(value, patterns, max_depth - 1) + return masked + + if isinstance(data, list): + return [mask_sensitive_data(item, patterns, max_depth - 1) for item in data] + + return data diff --git a/faststack_core/logging/structured_logger.py b/faststack_core/logging/structured_logger.py new file mode 100644 index 0000000..c8e2953 --- /dev/null +++ b/faststack_core/logging/structured_logger.py @@ -0,0 +1,33 @@ +import logging +import sys +from contextvars import ContextVar + +# This will be set by the correlation ID middleware +correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="") + + +def get_correlation_id() -> str: + return correlation_id_var.get() + + +class StructuredLogger: + """Production-grade logger with dual output. + + - Console: colored text for development (human-readable) + - JSON: structured for log aggregation (machine-readable) + """ + + def setup(self, app_name: str = "faststack", log_level: str = "INFO") -> logging.Logger: + """Configure and return a logger with the appropriate handlers.""" + logger = logging.getLogger(app_name) + logger.setLevel(getattr(logging, log_level.upper())) + logger.handlers.clear() + + # Console handler — simple text format + console = logging.StreamHandler(sys.stderr) + console.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + logger.addHandler(console) + + return logger diff --git a/faststack_core/middleware/correlation_id.py b/faststack_core/middleware/correlation_id.py new file mode 100644 index 0000000..ada8c0b --- /dev/null +++ b/faststack_core/middleware/correlation_id.py @@ -0,0 +1,27 @@ +import uuid +from collections.abc import Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from faststack_core.logging.structured_logger import correlation_id_var + + +class CorrelationIdMiddleware(BaseHTTPMiddleware): + """Assigns a unique correlation ID to every request. + + - Reads X-Correlation-ID from request header if present, otherwise generates UUID + - Sets the correlation_id_var contextvar so all logs include it + - Returns the correlation ID in the X-Correlation-ID response header + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override] + correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4())) + token = correlation_id_var.set(correlation_id) + try: + response: Response = await call_next(request) + response.headers["X-Correlation-ID"] = correlation_id + return response + finally: + correlation_id_var.reset(token) diff --git a/faststack_core/middleware/request_logging.py b/faststack_core/middleware/request_logging.py new file mode 100644 index 0000000..1497ec0 --- /dev/null +++ b/faststack_core/middleware/request_logging.py @@ -0,0 +1,31 @@ +import logging +import time +from collections.abc import Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from faststack_core.logging.structured_logger import get_correlation_id + +logger = logging.getLogger("faststack.request") + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """Logs HTTP request completion with method, path, status, and duration.""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override] + start = time.perf_counter() + response: Response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + logger.info( + "request_completed", + extra={ + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": round(duration_ms, 2), + "correlation_id": get_correlation_id(), + }, + ) + return response diff --git a/faststack_core/middleware/security_headers.py b/faststack_core/middleware/security_headers.py new file mode 100644 index 0000000..755c863 --- /dev/null +++ b/faststack_core/middleware/security_headers.py @@ -0,0 +1,24 @@ +from collections.abc import Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Cache-Control": "no-store", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", +} + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Adds standard security headers to every response.""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override] + response: Response = await call_next(request) + for header, value in SECURITY_HEADERS.items(): + response.headers.setdefault(header, value) + return response diff --git a/faststack_core/settings/config.py b/faststack_core/settings/config.py new file mode 100644 index 0000000..1fefd5a --- /dev/null +++ b/faststack_core/settings/config.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + + +@dataclass +class FastStackConfig: + """Configuration for setup_app(). + + Each boolean controls whether the corresponding middleware/feature + is registered. All enabled by default. + """ + + # Middleware toggles + correlation_id: bool = True + request_logging: bool = True + security_headers: bool = True + + # CORS (None = disabled, provide origins to enable) + cors_origins: list[str] | None = None + + # Logging + log_level: str = "INFO" + log_format: str = "json" # "json" or "text" + sensitive_fields: list[str] = field( + default_factory=lambda: [ + "password", + "secret", + "token", + "api_key", + "authorization", + ] + ) + + # Health checks + health_check: bool = True + health_check_path: str = "/health" + app_version: str = "0.1.0" + + # Exception handlers + exception_handlers: bool = True diff --git a/faststack_core/setup.py b/faststack_core/setup.py new file mode 100644 index 0000000..acd6ec3 --- /dev/null +++ b/faststack_core/setup.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from faststack_core.health.endpoints import create_health_router +from faststack_core.logging.structured_logger import StructuredLogger +from faststack_core.middleware.correlation_id import CorrelationIdMiddleware +from faststack_core.middleware.request_logging import RequestLoggingMiddleware +from faststack_core.middleware.security_headers import SecurityHeadersMiddleware +from faststack_core.settings.config import FastStackConfig + + +def setup_app(app: FastAPI, config: FastStackConfig | None = None) -> None: + """One-call setup for all FastStack middleware, handlers, and health checks. + + Each component can be individually disabled via the config. + + Middleware order note: Starlette processes middleware in reverse order of + registration. We register security_headers first (outermost = last to + execute on request, first on response), then request_logging, then + correlation_id (innermost = first to execute on request). This means + correlation_id is set before request_logging runs, so logs have the ID. + """ + if config is None: + config = FastStackConfig() + + # Logging + logger = StructuredLogger() + logger.setup(log_level=config.log_level) + + # Middleware (order matters — outermost first) + if config.security_headers: + app.add_middleware(SecurityHeadersMiddleware) + + if config.request_logging: + app.add_middleware(RequestLoggingMiddleware) + + if config.correlation_id: + app.add_middleware(CorrelationIdMiddleware) + + if config.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=config.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Exception handlers + if config.exception_handlers: + from faststack_core.exceptions.handlers import register_exception_handlers + + register_exception_handlers(app) + + # Health checks + if config.health_check: + health_router = create_health_router(app_version=config.app_version) + app.include_router(health_router) diff --git a/tests/test_core/test_health.py b/tests/test_core/test_health.py new file mode 100644 index 0000000..516f894 --- /dev/null +++ b/tests/test_core/test_health.py @@ -0,0 +1,30 @@ +"""Tests for health check endpoints.""" + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from faststack_core.health.endpoints import create_health_router + + +def _make_app(version: str = "1.0.0") -> FastAPI: + app = FastAPI() + app.include_router(create_health_router(app_version=version)) + return app + + +async def test_health_returns_ok(): + app = _make_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +async def test_health_detailed_includes_version(): + app = _make_app(version="2.5.0") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/detailed") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["version"] == "2.5.0" diff --git a/tests/test_core/test_logging.py b/tests/test_core/test_logging.py new file mode 100644 index 0000000..f5a9f0d --- /dev/null +++ b/tests/test_core/test_logging.py @@ -0,0 +1,128 @@ +"""Tests for the logging module: masking, structured logger, and config.""" + +import logging + +from faststack_core.logging.config import LogConfig +from faststack_core.logging.masking import MASK_VALUE, mask_sensitive_data +from faststack_core.logging.structured_logger import ( + StructuredLogger, + correlation_id_var, + get_correlation_id, +) + +# --------------------------------------------------------------------------- +# LogConfig +# --------------------------------------------------------------------------- + + +def test_log_config_defaults(): + config = LogConfig() + assert config.level == "INFO" + assert config.format == "json" + assert "password" in config.sensitive_patterns + + +def test_log_config_custom(): + config = LogConfig(level="DEBUG", format="text", app_name="myapp") + assert config.level == "DEBUG" + assert config.app_name == "myapp" + + +# --------------------------------------------------------------------------- +# Sensitive data masking +# --------------------------------------------------------------------------- + + +def test_mask_simple_dict(): + data = {"username": "alice", "password": "secret123"} + result = mask_sensitive_data(data) + assert result["username"] == "alice" + assert result["password"] == MASK_VALUE + + +def test_mask_does_not_mutate_input(): + data = {"password": "original"} + mask_sensitive_data(data) + assert data["password"] == "original" + + +def test_mask_nested_dict(): + data = {"user": {"name": "alice", "api_key": "abc123"}} + result = mask_sensitive_data(data) + assert result["user"]["name"] == "alice" + assert result["user"]["api_key"] == MASK_VALUE + + +def test_mask_list_of_dicts(): + data = [{"token": "xyz"}, {"name": "bob"}] + result = mask_sensitive_data(data) + assert result[0]["token"] == MASK_VALUE + assert result[1]["name"] == "bob" + + +def test_mask_case_insensitive(): + data = {"Password": "secret", "API_KEY": "abc"} + result = mask_sensitive_data(data) + assert result["Password"] == MASK_VALUE + assert result["API_KEY"] == MASK_VALUE + + +def test_mask_respects_depth_limit(): + data = {"level1": {"level2": {"level3": {"secret": "deep"}}}} + result = mask_sensitive_data(data, max_depth=2) + # At depth 2, we stop recursing — level3's contents pass through unmasked + assert result["level1"]["level2"]["level3"]["secret"] == "deep" + + +def test_mask_custom_patterns(): + data = {"ssn": "123-45-6789", "name": "alice"} + result = mask_sensitive_data(data, sensitive_patterns=["ssn"]) + assert result["ssn"] == MASK_VALUE + assert result["name"] == "alice" + + +def test_mask_non_dict_passthrough(): + assert mask_sensitive_data("just a string") == "just a string" + assert mask_sensitive_data(42) == 42 + assert mask_sensitive_data(None) is None + + +# --------------------------------------------------------------------------- +# Structured logger +# --------------------------------------------------------------------------- + + +def test_structured_logger_setup(): + sl = StructuredLogger() + logger = sl.setup(app_name="test-app", log_level="DEBUG") + assert isinstance(logger, logging.Logger) + assert logger.name == "test-app" + assert logger.level == logging.DEBUG + + +def test_structured_logger_has_handler(): + sl = StructuredLogger() + logger = sl.setup(app_name="test-handlers") + assert len(logger.handlers) > 0 + + +# --------------------------------------------------------------------------- +# Correlation ID contextvar +# --------------------------------------------------------------------------- + + +def test_correlation_id_default_empty(): + # Reset to default + token = correlation_id_var.set("") + try: + assert get_correlation_id() == "" + finally: + correlation_id_var.reset(token) + + +def test_correlation_id_set_and_get(): + token = correlation_id_var.set("test-123") + try: + assert get_correlation_id() == "test-123" + finally: + correlation_id_var.reset(token) diff --git a/tests/test_core/test_middleware.py b/tests/test_core/test_middleware.py new file mode 100644 index 0000000..e236cc1 --- /dev/null +++ b/tests/test_core/test_middleware.py @@ -0,0 +1,92 @@ +"""Tests for middleware: correlation ID, request logging, security headers.""" + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from faststack_core.middleware.correlation_id import CorrelationIdMiddleware +from faststack_core.middleware.request_logging import RequestLoggingMiddleware +from faststack_core.middleware.security_headers import SECURITY_HEADERS, SecurityHeadersMiddleware + + +def _make_app(*middleware_classes) -> FastAPI: + app = FastAPI() + + @app.get("/ping") + async def ping(): + return {"pong": True} + + for cls in middleware_classes: + app.add_middleware(cls) + return app + + +async def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +# --------------------------------------------------------------------------- +# Correlation ID +# --------------------------------------------------------------------------- + + +async def test_correlation_id_generated(): + """When no header is sent, a UUID is generated and returned.""" + app = _make_app(CorrelationIdMiddleware) + async with await _client(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + cid = resp.headers.get("X-Correlation-ID") + assert cid is not None + assert len(cid) == 36 # UUID format + + +async def test_correlation_id_propagated(): + """When X-Correlation-ID is sent, the same value is returned.""" + app = _make_app(CorrelationIdMiddleware) + async with await _client(app) as client: + resp = await client.get("/ping", headers={"X-Correlation-ID": "my-id-123"}) + assert resp.headers["X-Correlation-ID"] == "my-id-123" + + +# --------------------------------------------------------------------------- +# Request Logging +# --------------------------------------------------------------------------- + + +async def test_request_logging_does_not_break_response(): + """RequestLoggingMiddleware should not alter the response.""" + app = _make_app(RequestLoggingMiddleware) + async with await _client(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + assert resp.json() == {"pong": True} + + +# --------------------------------------------------------------------------- +# Security Headers +# --------------------------------------------------------------------------- + + +async def test_security_headers_present(): + """All standard security headers are added to responses.""" + app = _make_app(SecurityHeadersMiddleware) + async with await _client(app) as client: + resp = await client.get("/ping") + for header, value in SECURITY_HEADERS.items(): + assert resp.headers.get(header) == value, f"Missing or wrong: {header}" + + +async def test_security_headers_do_not_override_app_headers(): + """App-set headers take precedence over middleware defaults.""" + app = FastAPI() + + @app.get("/custom") + async def custom(): + from starlette.responses import JSONResponse + + return JSONResponse({"ok": True}, headers={"X-Frame-Options": "SAMEORIGIN"}) + + app.add_middleware(SecurityHeadersMiddleware) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/custom") + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" diff --git a/tests/test_core/test_setup.py b/tests/test_core/test_setup.py new file mode 100644 index 0000000..ca51b79 --- /dev/null +++ b/tests/test_core/test_setup.py @@ -0,0 +1,125 @@ +"""Tests for setup_app() — the one-call integration point.""" + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from faststack_core.exceptions.domain import NotFoundError +from faststack_core.settings.config import FastStackConfig +from faststack_core.setup import setup_app + + +def _make_app(config: FastStackConfig | None = None) -> FastAPI: + app = FastAPI() + setup_app(app, config) + return app + + +# --------------------------------------------------------------------------- +# Default setup +# --------------------------------------------------------------------------- + + +async def test_setup_registers_health(): + app = _make_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +async def test_setup_registers_exception_handlers(): + app = _make_app() + + @app.get("/fail") + async def fail(): + raise NotFoundError("gone") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/fail") + assert resp.status_code == 404 + assert resp.json()["type"] == "/errors/NotFoundError" + + +async def test_setup_registers_correlation_id(): + app = _make_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert "X-Correlation-ID" in resp.headers + + +async def test_setup_registers_security_headers(): + app = _make_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + assert resp.headers.get("X-Frame-Options") == "DENY" + + +# --------------------------------------------------------------------------- +# Disabled features +# --------------------------------------------------------------------------- + + +async def test_disable_health_check(): + app = _make_app(FastStackConfig(health_check=False)) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 404 # not registered + + +async def test_disable_exception_handlers(): + app = _make_app(FastStackConfig(exception_handlers=False)) + + @app.get("/fail") + async def fail(): + raise NotFoundError("gone") + + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/fail") + assert resp.status_code == 500 # unhandled, FastAPI default + + +async def test_disable_correlation_id(): + app = _make_app(FastStackConfig(correlation_id=False)) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert "X-Correlation-ID" not in resp.headers + + +async def test_disable_security_headers(): + app = _make_app(FastStackConfig(security_headers=False)) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert "X-Content-Type-Options" not in resp.headers + + +# --------------------------------------------------------------------------- +# CORS +# --------------------------------------------------------------------------- + + +async def test_cors_enabled(): + app = _make_app(FastStackConfig(cors_origins=["http://localhost:3000"])) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.options( + "/health", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.headers.get("access-control-allow-origin") == "http://localhost:3000" + + +async def test_cors_disabled_by_default(): + app = _make_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.options( + "/health", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "GET", + }, + ) + assert "access-control-allow-origin" not in resp.headers