cloudwright-ai-web 1.2.2__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/PKG-INFO +1 -1
  2. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/__init__.py +1 -1
  3. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/app.py +22 -0
  4. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/middleware.py +41 -1
  5. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/design.py +10 -6
  6. cloudwright_ai_web-1.3.0/cloudwright_web/routers/health.py +102 -0
  7. cloudwright_ai_web-1.3.0/tests/test_api_key_auth.py +98 -0
  8. cloudwright_ai_web-1.3.0/tests/test_design_returns_usage.py +108 -0
  9. cloudwright_ai_web-1.3.0/tests/test_health_endpoint.py +124 -0
  10. cloudwright_ai_web-1.3.0/tests/test_request_id.py +51 -0
  11. cloudwright_ai_web-1.2.2/cloudwright_web/routers/health.py +0 -43
  12. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/.gitignore +0 -0
  13. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/README.md +0 -0
  14. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/py.typed +0 -0
  15. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/__init__.py +0 -0
  16. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/catalog.py +0 -0
  17. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/chat.py +0 -0
  18. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/cost.py +0 -0
  19. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/diagram.py +0 -0
  20. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/diff.py +0 -0
  21. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/export.py +0 -0
  22. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/modules.py +0 -0
  23. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/validate.py +0 -0
  24. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/singletons.py +0 -0
  25. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
  26. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/assets/index-DoHV2oTE.js +0 -0
  27. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/index.html +0 -0
  28. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/streaming.py +0 -0
  29. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/index.html +0 -0
  30. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/package-lock.json +0 -0
  31. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/package.json +0 -0
  32. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/App.tsx +0 -0
  33. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
  34. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/BoundaryNode.tsx +0 -0
  35. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CatalogDrawer.tsx +0 -0
  36. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CloudServiceNode.tsx +0 -0
  37. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CostTable.tsx +0 -0
  38. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramControls.tsx +0 -0
  39. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramLegend.tsx +0 -0
  40. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ExportPanel.tsx +0 -0
  41. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/NodeSidePanel.tsx +0 -0
  42. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/SpecPanel.tsx +0 -0
  43. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/SummaryBar.tsx +0 -0
  44. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ValidationPanel.tsx +0 -0
  45. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/lib/icons.ts +0 -0
  46. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/main.tsx +0 -0
  47. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/tsconfig.json +0 -0
  48. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/vite.config.ts +0 -0
  49. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/pyproject.toml +0 -0
  50. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/__init__.py +0 -0
  51. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/conftest.py +0 -0
  52. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_agent_browser.py +0 -0
  53. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_api.py +0 -0
  54. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_api_behavioral.py +0 -0
  55. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_diagram_api.py +0 -0
  56. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_error_responses.py +0 -0
  57. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_modules_api.py +0 -0
  58. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_rate_limiting.py +0 -0
  59. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_streaming_api.py +0 -0
  60. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_usage_tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudwright-ai-web
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Web UI for Cloudwright architecture intelligence
5
5
  Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
6
6
  Project-URL: Repository, https://github.com/xmpuspus/cloudwright
@@ -1,6 +1,6 @@
1
1
  """Cloudwright Web — FastAPI backend for architecture intelligence."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.3.0"
4
4
 
5
5
 
6
6
  def __getattr__(name: str):
@@ -14,6 +14,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
14
14
  from cloudwright_web import __version__
15
15
  from cloudwright_web.middleware import ( # noqa: F401
16
16
  PathTraversalMiddleware,
17
+ RequestIdMiddleware,
17
18
  _rate_limiter,
18
19
  _RateLimiter,
19
20
  add_cors,
@@ -44,16 +45,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
44
45
  return response
45
46
 
46
47
 
48
+ def _docs_enabled() -> bool:
49
+ """Whether to expose /docs, /redoc, and /openapi.json.
50
+
51
+ Disabled by default in production (CLOUDWRIGHT_ENV=production) to reduce
52
+ reconnaissance surface. Override with CLOUDWRIGHT_DOCS_ENABLED=true.
53
+ """
54
+ explicit = os.environ.get("CLOUDWRIGHT_DOCS_ENABLED")
55
+ if explicit is not None:
56
+ return explicit.lower() == "true"
57
+ env = os.environ.get("CLOUDWRIGHT_ENV", "").lower()
58
+ return env != "production"
59
+
60
+
47
61
  def create_app() -> FastAPI:
62
+ docs_on = _docs_enabled()
48
63
  application = FastAPI(
49
64
  title="Cloudwright",
50
65
  version=__version__,
51
66
  description="Architecture intelligence for cloud engineers",
67
+ docs_url="/docs" if docs_on else None,
68
+ redoc_url="/redoc" if docs_on else None,
69
+ openapi_url="/openapi.json" if docs_on else None,
52
70
  )
53
71
 
72
+ # RequestIdMiddleware MUST be added LAST so Starlette dispatches it FIRST
73
+ # (Starlette runs middleware in reverse-add order). This way every later
74
+ # middleware's log lines carry the request_id.
54
75
  application.add_middleware(SecurityHeadersMiddleware)
55
76
  application.add_middleware(PathTraversalMiddleware)
56
77
  add_cors(application)
78
+ application.add_middleware(RequestIdMiddleware)
57
79
 
58
80
  application.include_router(health_router, prefix="/api")
59
81
  application.include_router(design_router, prefix="/api")
@@ -2,12 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hmac
5
6
  import os
6
7
  import threading
7
8
  import time
9
+ import uuid
8
10
  from collections import deque
9
11
  from urllib.parse import unquote
10
12
 
13
+ import structlog
11
14
  from fastapi import HTTPException, Request
12
15
  from fastapi.middleware.cors import CORSMiddleware
13
16
  from fastapi.responses import JSONResponse
@@ -22,6 +25,32 @@ class PathTraversalMiddleware(BaseHTTPMiddleware):
22
25
  return await call_next(request)
23
26
 
24
27
 
28
+ class RequestIdMiddleware(BaseHTTPMiddleware):
29
+ """Attach a request correlation ID to every request.
30
+
31
+ - Reads X-Request-Id from incoming headers, otherwise mints a UUID4 hex.
32
+ - Binds the value into structlog's contextvars for the duration of the
33
+ request so every log line carries it.
34
+ - Echoes the same value back as the X-Request-Id response header.
35
+ """
36
+
37
+ async def dispatch(self, request: Request, call_next):
38
+ incoming = request.headers.get("x-request-id", "").strip()
39
+ request_id = incoming or uuid.uuid4().hex
40
+
41
+ # Stash on request.state so handlers can read it if they need to.
42
+ request.state.request_id = request_id
43
+
44
+ structlog.contextvars.bind_contextvars(request_id=request_id)
45
+ try:
46
+ response = await call_next(request)
47
+ finally:
48
+ structlog.contextvars.unbind_contextvars("request_id")
49
+
50
+ response.headers["X-Request-Id"] = request_id
51
+ return response
52
+
53
+
25
54
  def add_cors(app):
26
55
  origins = os.environ.get("CLOUDWRIGHT_CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
27
56
  app.add_middleware(
@@ -38,10 +67,21 @@ _API_KEY = os.environ.get("CLOUDWRIGHT_API_KEY")
38
67
 
39
68
 
40
69
  def check_api_key(request: Request):
70
+ """Validate the X-API-Key header in constant time.
71
+
72
+ Using ``hmac.compare_digest`` prevents timing-based recovery of the
73
+ configured key. Both sides are encoded to bytes (utf-8); mismatched
74
+ lengths are rejected by ``compare_digest`` itself but we short-circuit
75
+ empty input first to avoid leaking even a length signal.
76
+ """
41
77
  if not _API_KEY:
42
78
  return None
43
79
  provided = request.headers.get("x-api-key", "")
44
- if provided != _API_KEY:
80
+ if not provided:
81
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
82
+ expected_bytes = _API_KEY.encode("utf-8")
83
+ provided_bytes = provided.encode("utf-8")
84
+ if not hmac.compare_digest(provided_bytes, expected_bytes):
45
85
  raise HTTPException(status_code=401, detail="Invalid or missing API key")
46
86
  return None
47
87
 
@@ -52,7 +52,8 @@ async def design(req: DesignRequest, request: Request):
52
52
  spec = spec.model_copy(update={"cost_estimate": cost_estimate})
53
53
  except Exception:
54
54
  log.warning("Cost estimation failed in design endpoint", exc_info=True)
55
- return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml()}
55
+ usage = getattr(architect, "last_usage", None) or {}
56
+ return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml(), "usage": usage}
56
57
  except RuntimeError as e:
57
58
  if "No LLM provider" in str(e):
58
59
  return error_response("missing_api_key", str(e), "Set an LLM provider API key in your environment", 503)
@@ -83,7 +84,8 @@ async def design_stream(req: DesignRequest, request: Request):
83
84
  yield sse_event("error", message=str(e))
84
85
  return
85
86
 
86
- yield sse_event("generated", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml())
87
+ usage = getattr(architect, "last_usage", None) or {}
88
+ yield sse_event("generated", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
87
89
 
88
90
  yield sse_event("costing", message="Estimating cost...")
89
91
  try:
@@ -107,7 +109,7 @@ async def design_stream(req: DesignRequest, request: Request):
107
109
  log.warning("Validation failed in design stream", exc_info=True)
108
110
  yield sse_event("validated", passed=None, total=None)
109
111
 
110
- yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml())
112
+ yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
111
113
 
112
114
  return StreamingResponse(event_generator(), media_type="text/event-stream")
113
115
 
@@ -124,7 +126,8 @@ async def modify(req: ModifyRequest, request: Request):
124
126
  updated = await asyncio.wait_for(asyncio.to_thread(architect.modify, spec, req.instruction), timeout=120)
125
127
  except asyncio.TimeoutError:
126
128
  return error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
127
- return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml()}
129
+ usage = getattr(architect, "last_usage", None) or {}
130
+ return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml(), "usage": usage}
128
131
  except Exception:
129
132
  log.exception("Modify endpoint failed")
130
133
  return error_response("internal_error", "Internal server error", "Check server logs for details", 500)
@@ -148,7 +151,8 @@ async def modify_stream(req: ModifyRequest, request: Request):
148
151
  yield sse_event("error", message=str(e))
149
152
  return
150
153
 
151
- yield sse_event("modified", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml())
154
+ usage = getattr(architect, "last_usage", None) or {}
155
+ yield sse_event("modified", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
152
156
 
153
157
  yield sse_event("costing", message="Estimating cost...")
154
158
  try:
@@ -159,6 +163,6 @@ async def modify_stream(req: ModifyRequest, request: Request):
159
163
  log.warning("Cost estimation failed in modify stream", exc_info=True)
160
164
  yield sse_event("costed", cost_estimate=None)
161
165
 
162
- yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml())
166
+ yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
163
167
 
164
168
  return StreamingResponse(event_generator(), media_type="text/event-stream")
@@ -0,0 +1,102 @@
1
+ """GET /api/health, /api/version, and static icon serving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+
12
+ import cloudwright_web.singletons as _singletons
13
+
14
+ router = APIRouter()
15
+
16
+ # Captured at module import for uptime reporting.
17
+ _START_MONOTONIC = time.monotonic()
18
+
19
+
20
+ def _llm_provider_and_model() -> tuple[str | None, str | None]:
21
+ """Return (provider, model_name) based on env, without instantiating clients."""
22
+ if os.environ.get("ANTHROPIC_API_KEY"):
23
+ from cloudwright.llm.anthropic import GENERATE_MODEL as ANTHROPIC_MODEL
24
+
25
+ return "anthropic", ANTHROPIC_MODEL
26
+ if os.environ.get("OPENAI_API_KEY"):
27
+ from cloudwright.llm.openai import GENERATE_MODEL as OPENAI_MODEL
28
+
29
+ return "openai", OPENAI_MODEL
30
+ return None, None
31
+
32
+
33
+ def _version_payload() -> dict:
34
+ from cloudwright import __version__
35
+
36
+ provider, model = _llm_provider_and_model()
37
+ return {
38
+ "version": __version__,
39
+ "build_sha": os.environ.get("CLOUDWRIGHT_BUILD_SHA"),
40
+ "llm_provider": provider,
41
+ "llm_model": model,
42
+ }
43
+
44
+
45
+ @router.get("/health")
46
+ def health():
47
+ has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
48
+ if not has_llm_key:
49
+ return JSONResponse(
50
+ status_code=503,
51
+ content={
52
+ "status": "degraded",
53
+ "reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)",
54
+ **_version_payload(),
55
+ "catalog_loaded": False,
56
+ "catalog_size": 0,
57
+ "uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
58
+ },
59
+ )
60
+
61
+ catalog_loaded = False
62
+ catalog_size = 0
63
+ try:
64
+ catalog = _singletons.get_catalog()
65
+ # Try to size the catalog; fall back to a sample search if no len.
66
+ try:
67
+ catalog_size = len(catalog) # type: ignore[arg-type]
68
+ except TypeError:
69
+ catalog_size = len(catalog.search(query="m5", limit=1))
70
+ catalog_loaded = True
71
+ except Exception:
72
+ catalog_loaded = False
73
+
74
+ body = {
75
+ "status": "ok" if catalog_loaded else "degraded",
76
+ **_version_payload(),
77
+ "catalog_loaded": catalog_loaded,
78
+ "catalog_size": catalog_size,
79
+ "uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
80
+ }
81
+ if not catalog_loaded:
82
+ # Readiness probes (Kubernetes) should treat catalog-load failure as Not Ready.
83
+ return JSONResponse(status_code=503, content=body)
84
+ return body
85
+
86
+
87
+ @router.get("/version")
88
+ def version():
89
+ return _version_payload()
90
+
91
+
92
+ @router.get("/icons/{provider}/{service}.svg")
93
+ def get_icon(provider: str, service: str):
94
+ import cloudwright
95
+
96
+ icons_dir = Path(cloudwright.__file__).parent / "data" / "icons"
97
+ icon_path = icons_dir / provider / f"{service}.svg"
98
+ if not icon_path.exists():
99
+ raise HTTPException(status_code=404, detail=f"Icon not found: {provider}/{service}")
100
+ if not icon_path.resolve().is_relative_to(icons_dir.resolve()):
101
+ raise HTTPException(status_code=404, detail="Invalid path")
102
+ return FileResponse(str(icon_path), media_type="image/svg+xml")
@@ -0,0 +1,98 @@
1
+ """Tests for the constant-time API key check.
2
+
3
+ The pre-v1.3 ``check_api_key`` used ``provided != _API_KEY`` which leaks the
4
+ key length and (under some interpreters) prefix-match timing. v1.3 switches
5
+ to ``hmac.compare_digest`` on bytes, plus an explicit empty-input short-circuit.
6
+
7
+ Tests patch the module-level ``_API_KEY`` directly via monkeypatch and restore
8
+ it on teardown — we avoid ``importlib.reload`` because reloading the
9
+ middleware module wipes the singleton ``_rate_limiter`` and breaks
10
+ unrelated tests that rely on the same instance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import cloudwright_web.middleware as middleware
16
+ import pytest
17
+ from fastapi import HTTPException
18
+
19
+
20
+ class _StubRequest:
21
+ """Just enough of starlette.Request to drive ``check_api_key``."""
22
+
23
+ def __init__(self, header_value: str | None):
24
+ self.headers = {"x-api-key": header_value} if header_value is not None else {}
25
+
26
+
27
+ @pytest.fixture
28
+ def with_key(monkeypatch):
29
+ """Set the configured API key for the duration of one test."""
30
+
31
+ def _set(value: str | None):
32
+ monkeypatch.setattr(middleware, "_API_KEY", value)
33
+
34
+ return _set
35
+
36
+
37
+ def test_returns_none_when_no_api_key_configured(with_key):
38
+ with_key(None)
39
+ # Even an explicitly garbage header is allowed when no key is set.
40
+ assert middleware.check_api_key(_StubRequest("anything")) is None
41
+
42
+
43
+ def test_correct_key_passes(with_key):
44
+ with_key("secret-token-abc123")
45
+ assert middleware.check_api_key(_StubRequest("secret-token-abc123")) is None
46
+
47
+
48
+ def test_wrong_key_same_length_raises_401(with_key):
49
+ """Two equal-length keys must compare via hmac.compare_digest."""
50
+ with_key("secret-token-abc123")
51
+ with pytest.raises(HTTPException) as exc:
52
+ middleware.check_api_key(_StubRequest("secret-token-XYZ999"))
53
+ assert exc.value.status_code == 401
54
+
55
+
56
+ def test_wrong_key_different_length_raises_401(with_key):
57
+ with_key("secret-token-abc123")
58
+ with pytest.raises(HTTPException) as exc:
59
+ middleware.check_api_key(_StubRequest("short"))
60
+ assert exc.value.status_code == 401
61
+
62
+
63
+ def test_missing_header_raises_401(with_key):
64
+ with_key("secret-token-abc123")
65
+ with pytest.raises(HTTPException) as exc:
66
+ middleware.check_api_key(_StubRequest(None))
67
+ assert exc.value.status_code == 401
68
+
69
+
70
+ def test_empty_header_raises_401(with_key):
71
+ with_key("secret-token-abc123")
72
+ with pytest.raises(HTTPException) as exc:
73
+ middleware.check_api_key(_StubRequest(""))
74
+ assert exc.value.status_code == 401
75
+
76
+
77
+ def test_uses_hmac_compare_digest(with_key, monkeypatch):
78
+ """Sanity check: the implementation actually calls hmac.compare_digest.
79
+
80
+ We monkeypatch ``hmac.compare_digest`` and assert it is invoked with the
81
+ UTF-8 encoded bytes of both sides — this pins the constant-time semantic.
82
+ """
83
+ with_key("abc")
84
+ calls: list[tuple[bytes, bytes]] = []
85
+ real = middleware.hmac.compare_digest
86
+
87
+ def spy(a, b):
88
+ calls.append((a, b))
89
+ return real(a, b)
90
+
91
+ monkeypatch.setattr(middleware.hmac, "compare_digest", spy)
92
+ with pytest.raises(HTTPException):
93
+ middleware.check_api_key(_StubRequest("xyz"))
94
+ assert calls, "hmac.compare_digest should have been called"
95
+ a, b = calls[0]
96
+ assert isinstance(a, bytes) and isinstance(b, bytes)
97
+ assert a == b"xyz"
98
+ assert b == b"abc"
@@ -0,0 +1,108 @@
1
+ """/api/design and /api/modify must surface LLM usage in the response.
2
+
3
+ Pre-fix: only /api/chat returned usage; design and modify dropped it. UI
4
+ clients had no way to surface tokens or cost for those endpoints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+
14
+
15
+ @pytest.fixture
16
+ def client():
17
+ from cloudwright_web.app import app
18
+
19
+ return TestClient(app)
20
+
21
+
22
+ def _make_spec():
23
+ from cloudwright.spec import ArchSpec, Component, Connection
24
+
25
+ return ArchSpec(
26
+ name="Test App",
27
+ provider="aws",
28
+ region="us-east-1",
29
+ components=[
30
+ Component(id="web", service="ec2", provider="aws", label="Web", tier=2, config={}),
31
+ Component(id="db", service="rds", provider="aws", label="DB", tier=3, config={}),
32
+ ],
33
+ connections=[Connection(source="web", target="db", label="SQL")],
34
+ )
35
+
36
+
37
+ class TestDesignReturnsUsage:
38
+ def test_design_response_includes_usage_field(self, client):
39
+ spec = _make_spec()
40
+ architect = MagicMock()
41
+ architect.design.return_value = spec
42
+ architect.last_usage = {
43
+ "model": "claude-sonnet-4-6",
44
+ "input_tokens": 500,
45
+ "output_tokens": 200,
46
+ "cached_tokens": 0,
47
+ "cost_usd": 0.0045,
48
+ "latency_ms": 1234,
49
+ }
50
+
51
+ with patch("cloudwright_web.singletons.get_architect", return_value=architect):
52
+ resp = client.post(
53
+ "/api/design",
54
+ json={"description": "3-tier web app on AWS", "provider": "aws", "region": "us-east-1"},
55
+ )
56
+
57
+ assert resp.status_code == 200, resp.text
58
+ data = resp.json()
59
+ assert "usage" in data
60
+ usage = data["usage"]
61
+ assert usage["model"] == "claude-sonnet-4-6"
62
+ assert usage["input_tokens"] == 500
63
+ assert usage["output_tokens"] == 200
64
+ assert usage["cost_usd"] == 0.0045
65
+
66
+ def test_modify_response_includes_usage_field(self, client):
67
+ original = _make_spec()
68
+ modified = original
69
+ architect = MagicMock()
70
+ architect.modify.return_value = modified
71
+ architect.last_usage = {
72
+ "model": "claude-haiku-4-5-20251001",
73
+ "input_tokens": 100,
74
+ "output_tokens": 50,
75
+ "cached_tokens": 80,
76
+ "cost_usd": 0.00028,
77
+ "latency_ms": 400,
78
+ }
79
+
80
+ with patch("cloudwright_web.singletons.get_architect", return_value=architect):
81
+ resp = client.post(
82
+ "/api/modify",
83
+ json={"spec": original.model_dump(exclude_none=True), "instruction": "add a cache layer"},
84
+ )
85
+
86
+ assert resp.status_code == 200, resp.text
87
+ data = resp.json()
88
+ assert "usage" in data
89
+ # Haiku model surfaces — and the cost is the cheap rate, not Sonnet's.
90
+ assert data["usage"]["model"].startswith("claude-haiku")
91
+ assert data["usage"]["cost_usd"] == 0.00028
92
+
93
+ def test_design_usage_empty_when_unavailable(self, client):
94
+ """Backwards-compat: if architect.last_usage is empty (e.g. template
95
+ match path), the response still includes the field but as {}."""
96
+ spec = _make_spec()
97
+ architect = MagicMock()
98
+ architect.design.return_value = spec
99
+ architect.last_usage = {}
100
+
101
+ with patch("cloudwright_web.singletons.get_architect", return_value=architect):
102
+ resp = client.post(
103
+ "/api/design",
104
+ json={"description": "3-tier web app on AWS", "provider": "aws", "region": "us-east-1"},
105
+ )
106
+
107
+ assert resp.status_code == 200
108
+ assert resp.json()["usage"] == {}
@@ -0,0 +1,124 @@
1
+ """Tests for /api/health and /api/version (audit-fix v1.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+
13
+
14
+ @pytest.fixture
15
+ def client(monkeypatch):
16
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
17
+ from cloudwright_web.app import create_app
18
+ from fastapi.testclient import TestClient
19
+
20
+ return TestClient(create_app())
21
+
22
+
23
+ def test_health_returns_version_and_uptime(client):
24
+ r = client.get("/api/health", headers={"X-API-Key": "test-key"})
25
+ # status code may be 200 or 503 depending on catalog load in test env
26
+ body = r.json()
27
+ assert "version" in body
28
+ assert isinstance(body["version"], str)
29
+ assert "uptime_s" in body
30
+ assert isinstance(body["uptime_s"], (int, float))
31
+ assert "catalog_loaded" in body
32
+ assert "catalog_size" in body
33
+ assert "llm_provider" in body
34
+ assert "llm_model" in body
35
+
36
+
37
+ def test_health_503_when_catalog_fails(client):
38
+ """When catalog loading fails, health returns 503 (Kubernetes readiness)."""
39
+ with patch("cloudwright_web.singletons.get_catalog", side_effect=RuntimeError("catalog dead")):
40
+ r = client.get("/api/health", headers={"X-API-Key": "test-key"})
41
+ assert r.status_code == 503
42
+ body = r.json()
43
+ assert body["catalog_loaded"] is False
44
+ assert body["status"] == "degraded"
45
+
46
+
47
+ def test_health_503_when_no_llm_key(monkeypatch):
48
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
49
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
50
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
51
+ from cloudwright_web.app import create_app
52
+ from fastapi.testclient import TestClient
53
+
54
+ c = TestClient(create_app())
55
+ r = c.get("/api/health")
56
+ assert r.status_code == 503
57
+ body = r.json()
58
+ assert body["status"] == "degraded"
59
+ assert "version" in body
60
+
61
+
62
+ def test_version_endpoint(client):
63
+ r = client.get("/api/version")
64
+ assert r.status_code == 200
65
+ body = r.json()
66
+ assert set(body.keys()) == {"version", "build_sha", "llm_provider", "llm_model"}
67
+ assert isinstance(body["version"], str)
68
+
69
+
70
+ def test_version_includes_build_sha_from_env(monkeypatch):
71
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
72
+ monkeypatch.setenv("CLOUDWRIGHT_BUILD_SHA", "abc1234")
73
+ from cloudwright_web.app import create_app
74
+ from fastapi.testclient import TestClient
75
+
76
+ c = TestClient(create_app())
77
+ r = c.get("/api/version")
78
+ assert r.json()["build_sha"] == "abc1234"
79
+
80
+
81
+ def _is_openapi_json(response) -> bool:
82
+ """A real OpenAPI doc is JSON whose body starts with `{"openapi"`.
83
+
84
+ The SPA fallback (frontend index.html) returns 200 with text/html, which
85
+ we treat as "openapi disabled" — Swagger UI cannot render against it.
86
+ """
87
+ if response.status_code != 200:
88
+ return False
89
+ if "application/json" not in response.headers.get("content-type", ""):
90
+ return False
91
+ return response.text.lstrip().startswith('{"openapi"')
92
+
93
+
94
+ def test_docs_disabled_in_production(monkeypatch):
95
+ monkeypatch.setenv("CLOUDWRIGHT_ENV", "production")
96
+ monkeypatch.delenv("CLOUDWRIGHT_DOCS_ENABLED", raising=False)
97
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
98
+ from cloudwright_web.app import create_app
99
+ from fastapi.testclient import TestClient
100
+
101
+ c = TestClient(create_app())
102
+ assert not _is_openapi_json(c.get("/openapi.json"))
103
+
104
+
105
+ def test_docs_enabled_outside_production(monkeypatch):
106
+ monkeypatch.delenv("CLOUDWRIGHT_ENV", raising=False)
107
+ monkeypatch.delenv("CLOUDWRIGHT_DOCS_ENABLED", raising=False)
108
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
109
+ from cloudwright_web.app import create_app
110
+ from fastapi.testclient import TestClient
111
+
112
+ c = TestClient(create_app())
113
+ assert _is_openapi_json(c.get("/openapi.json"))
114
+
115
+
116
+ def test_docs_enabled_override(monkeypatch):
117
+ monkeypatch.setenv("CLOUDWRIGHT_ENV", "production")
118
+ monkeypatch.setenv("CLOUDWRIGHT_DOCS_ENABLED", "true")
119
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
120
+ from cloudwright_web.app import create_app
121
+ from fastapi.testclient import TestClient
122
+
123
+ c = TestClient(create_app())
124
+ assert _is_openapi_json(c.get("/openapi.json"))
@@ -0,0 +1,51 @@
1
+ """Tests for RequestIdMiddleware (audit-fix v1.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+
12
+
13
+ @pytest.fixture
14
+ def client(monkeypatch):
15
+ monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
16
+ from cloudwright_web.app import create_app
17
+ from fastapi.testclient import TestClient
18
+
19
+ return TestClient(create_app())
20
+
21
+
22
+ def test_request_id_minted_when_missing(client):
23
+ r = client.get("/api/version")
24
+ assert r.status_code == 200
25
+ rid = r.headers.get("X-Request-Id")
26
+ assert rid is not None
27
+ assert len(rid) >= 16
28
+
29
+
30
+ def test_request_id_preserved_when_provided(client):
31
+ incoming = "deadbeefdeadbeefdeadbeefdeadbeef"
32
+ r = client.get("/api/version", headers={"X-Request-Id": incoming})
33
+ assert r.status_code == 200
34
+ assert r.headers["X-Request-Id"] == incoming
35
+
36
+
37
+ def test_request_ids_are_unique_per_request(client):
38
+ rid1 = client.get("/api/version").headers["X-Request-Id"]
39
+ rid2 = client.get("/api/version").headers["X-Request-Id"]
40
+ assert rid1 != rid2
41
+
42
+
43
+ def test_request_id_present_on_404(client):
44
+ r = client.get("/api/this-does-not-exist")
45
+ # path traversal middleware lets unknown routes through to 404 from FastAPI
46
+ assert "X-Request-Id" in r.headers
47
+
48
+
49
+ def test_request_id_present_on_health(client):
50
+ r = client.get("/api/health", headers={"X-API-Key": "test-key"})
51
+ assert "X-Request-Id" in r.headers
@@ -1,43 +0,0 @@
1
- """GET /api/health and static icon serving."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- from pathlib import Path
7
-
8
- from fastapi import APIRouter, HTTPException
9
- from fastapi.responses import FileResponse, JSONResponse
10
-
11
- import cloudwright_web.singletons as _singletons
12
-
13
- router = APIRouter()
14
-
15
-
16
- @router.get("/health")
17
- def health():
18
- # Check LLM key presence
19
- has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
20
- if not has_llm_key:
21
- return JSONResponse(
22
- status_code=503,
23
- content={"status": "degraded", "reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)"},
24
- )
25
- try:
26
- catalog = _singletons.get_catalog()
27
- results = catalog.search(query="m5", limit=1)
28
- return {"status": "ok", "catalog_loaded": True, "sample_count": len(results)}
29
- except Exception:
30
- return {"status": "ok", "catalog_loaded": False}
31
-
32
-
33
- @router.get("/icons/{provider}/{service}.svg")
34
- def get_icon(provider: str, service: str):
35
- import cloudwright
36
-
37
- icons_dir = Path(cloudwright.__file__).parent / "data" / "icons"
38
- icon_path = icons_dir / provider / f"{service}.svg"
39
- if not icon_path.exists():
40
- raise HTTPException(status_code=404, detail=f"Icon not found: {provider}/{service}")
41
- if not icon_path.resolve().is_relative_to(icons_dir.resolve()):
42
- raise HTTPException(status_code=404, detail="Invalid path")
43
- return FileResponse(str(icon_path), media_type="image/svg+xml")