fastapi-m8 3.1.0__tar.gz → 3.2.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 (50) hide show
  1. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/CHANGELOG.md +29 -0
  2. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/PKG-INFO +12 -4
  3. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/README.md +11 -3
  4. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_app.py +7 -6
  5. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_compat.py +6 -0
  6. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_version.py +1 -1
  7. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/pyproject.toml +1 -1
  8. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_app.py +107 -7
  9. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.codacy.yml +0 -0
  10. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.dockerignore +0 -0
  11. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.env.example +0 -0
  12. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.gitattributes +0 -0
  13. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/FUNDING.yml +0 -0
  14. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/dependabot.yml +0 -0
  15. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/CI.yaml +0 -0
  16. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/PiPy.yml +0 -0
  17. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/ci.yml +0 -0
  18. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.gitignore +0 -0
  19. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.pydocstyle +0 -0
  20. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/LICENSE +0 -0
  21. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/SECURITY.md +0 -0
  22. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/__init__.py +0 -0
  23. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_async_stub.py +0 -0
  24. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_deps.py +0 -0
  25. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_engine.py +0 -0
  26. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_events.py +0 -0
  27. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_health.py +0 -0
  28. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_internal_auth.py +0 -0
  29. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_revocation.py +0 -0
  30. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/config.py +0 -0
  31. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/__init__.py +0 -0
  32. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  33. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/pre_start.py +0 -0
  34. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/__init__.py +0 -0
  35. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/conftest.py +0 -0
  36. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_app_extra.py +0 -0
  37. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_async_stub.py +0 -0
  38. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_compat.py +0 -0
  39. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_config.py +0 -0
  40. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_config_file_secrets.py +0 -0
  41. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_deps.py +0 -0
  42. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_engine.py +0 -0
  43. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_event_signing_gate.py +0 -0
  44. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_events.py +0 -0
  45. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_health.py +0 -0
  46. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_host_header_routing.py +0 -0
  47. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_internal_auth.py +0 -0
  48. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_meta.py +0 -0
  49. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_pre_start.py +0 -0
  50. {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_revocation.py +0 -0
@@ -5,6 +5,35 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
5
5
 
6
6
  ---
7
7
 
8
+ ## [3.2.0] — 2026-06-28 · ungated `/health` constant liveness body (item 9.4 Design B)
9
+
10
+ > **MINOR — additive, backward-compatible.** The only behaviour change is in the ungated
11
+ > `/health` response: callers without a valid `HEALTH_DETAIL_CREDENTIAL` now always
12
+ > receive `200 {"status":"ok"}` instead of the real aggregate status. Callers that
13
+ > already present the credential are unaffected — they still get the real status, per-check
14
+ > detail, and the 503 HTTP code when checks fail. `auth-sdk-m8` floor unchanged at
15
+ > `>=2.1.0,<3.0.0`.
16
+
17
+ ### Changed
18
+
19
+ - **Ungated `/health` body is now a constant liveness response (item 9.4 Design B).**
20
+ Callers without a valid `HEALTH_DETAIL_CREDENTIAL` (or `detail_public=True`) always
21
+ receive `200 {"status":"ok"}`, regardless of the real aggregate health. Previously the
22
+ ungated branch returned `{"status": overall.value}`, which leaked `degraded` — a
23
+ public attack-timing oracle that signals when fail-open degradation is active.
24
+
25
+ Health checks are **not run** for ungated requests (pure liveness, no dependency
26
+ probing); caching is unaffected for credentialed callers. Credentialed callers
27
+ (valid `X-Internal-Token` matching `HEALTH_DETAIL_CREDENTIAL`, or `detail_public=True`)
28
+ continue to receive the real aggregate status, the per-check body, and the correct
29
+ HTTP status code (200 or 503).
30
+
31
+ - **README `Health Detail Gating` section updated** to document the constant ungated
32
+ body and the Design B rationale; the defaults-by-layer table row for
33
+ `HEALTH_DETAIL_CREDENTIAL` updated accordingly.
34
+
35
+ ---
36
+
8
37
  ## [3.1.0] — 2026-06-24/25 · consumer-side post-1.0 remediation (9.1 / 5.5 / 7.x.1 / 9.2 / 10.1) + event-stream 9.1 follow-on
9
38
 
10
39
  > **MINOR — additive, backward-compatible.** Default behaviour is unchanged: a consumer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-m8
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: FastAPI application framework for m8 consumer microservices.
5
5
  Author-email: Eli Serra <e.serra173@gmail.com>
6
6
  License: Apache License
@@ -724,11 +724,19 @@ do not connect to Redis directly.
724
724
  | `REDIS_PASSWORD` | Redis password |
725
725
  | `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
726
726
 
727
- ### Health Detail Gating (item 9.3)
727
+ ### Health Detail Gating (items 9.3 + 9.4)
728
728
 
729
729
  | Variable | Default | Description |
730
730
  |---|---|---|
731
- | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns only `{"status": "ok/fail"}` to all callers (fail-closed). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the full per-check breakdown. **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
731
+ | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
732
+
733
+ > **Constant ungated body (item 9.4 Design B):** ungated callers always receive
734
+ > `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
735
+ > or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
736
+ > `degraded` response is a timing oracle that signals fail-open degradation is active.
737
+ > Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
738
+ > per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
739
+ > `{API_PREFIX}/ping` are unaffected.
732
740
 
733
741
  > **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
734
742
  > `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
@@ -827,7 +835,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
827
835
  | `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
828
836
  | `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
829
837
  | `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
830
- | `HEALTH_DETAIL_CREDENTIAL` (item 9.3) | unset = `/health` returns only shallow status to all callers (fail-closed, detail never shown); set = `X-Internal-Token` must match to receive the full per-check breakdown. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = no detail, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
838
+ | `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
831
839
  | `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
832
840
  | `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
833
841
 
@@ -468,11 +468,19 @@ do not connect to Redis directly.
468
468
  | `REDIS_PASSWORD` | Redis password |
469
469
  | `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
470
470
 
471
- ### Health Detail Gating (item 9.3)
471
+ ### Health Detail Gating (items 9.3 + 9.4)
472
472
 
473
473
  | Variable | Default | Description |
474
474
  |---|---|---|
475
- | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns only `{"status": "ok/fail"}` to all callers (fail-closed). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the full per-check breakdown. **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
475
+ | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
476
+
477
+ > **Constant ungated body (item 9.4 Design B):** ungated callers always receive
478
+ > `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
479
+ > or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
480
+ > `degraded` response is a timing oracle that signals fail-open degradation is active.
481
+ > Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
482
+ > per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
483
+ > `{API_PREFIX}/ping` are unaffected.
476
484
 
477
485
  > **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
478
486
  > `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
@@ -571,7 +579,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
571
579
  | `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
572
580
  | `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
573
581
  | `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
574
- | `HEALTH_DETAIL_CREDENTIAL` (item 9.3) | unset = `/health` returns only shallow status to all callers (fail-closed, detail never shown); set = `X-Internal-Token` must match to receive the full per-check breakdown. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = no detail, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
582
+ | `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
575
583
  | `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
576
584
  | `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
577
585
 
@@ -368,14 +368,15 @@ def _register_health_route(
368
368
  return JSONResponse(
369
369
  {"status": "initializing", "ready": False}, status_code=503
370
370
  )
371
- results, overall, code = await _gather_health_results(
372
- app, checks, config.timeout, config.policy, config.cache_ttl
373
- )
374
- logger.debug("health: %s (%d checks)", overall.value, len(results))
375
- body: dict[str, Any] = {"status": overall.value}
376
371
  if config.detail_public or await _is_authorized(request):
372
+ results, overall, code = await _gather_health_results(
373
+ app, checks, config.timeout, config.policy, config.cache_ttl
374
+ )
375
+ logger.debug("health: %s (%d checks)", overall.value, len(results))
376
+ body: dict[str, Any] = {"status": overall.value}
377
377
  body |= _build_health_body(results, service_name, service_version)
378
- return JSONResponse(body, status_code=code)
378
+ return JSONResponse(body, status_code=code)
379
+ return JSONResponse({"status": "ok"})
379
380
 
380
381
 
381
382
  def _register_meta_routes(app: FastAPI, settings: ConsumerServiceSettings) -> None:
@@ -68,6 +68,12 @@ COMPAT_MATRIX: dict[str, dict[str, str]] = {
68
68
  # consumer-side 5.5 / 7.x.1 test+observability and 9.2/10.1 docs. The post-1.0
69
69
  # breaking-removal majors stay deferred (see CHANGELOG). See CHANGELOG.
70
70
  "3.1": {"auth-sdk-m8": ">=2.1.0,<3.0.0"},
71
+ # 3.2 (MINOR) hardens the ungated /health branch to a constant liveness response
72
+ # (item 9.4 Design B): ungated callers always receive 200 {"status":"ok"};
73
+ # only credentialed callers (HEALTH_DETAIL_CREDENTIAL) see the real aggregate
74
+ # status, per-check detail, and the 503 HTTP code on FAIL. No SDK change required;
75
+ # auth-sdk-m8 floor stays at >=2.1.0. See CHANGELOG.
76
+ "3.2": {"auth-sdk-m8": ">=2.1.0,<3.0.0"},
71
77
  }
72
78
 
73
79
  _EXTRAS = "[config,security,fastapi,observability]"
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "3.1.0"
3
+ __version__ = "3.2.0"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-m8"
7
- version = "3.1.0"
7
+ version = "3.2.0"
8
8
  description = "FastAPI application framework for m8 consumer microservices."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -202,18 +202,117 @@ async def test_distinct_credentials_do_not_raise(test_router: APIRouter) -> None
202
202
  assert a.state.service_ready is True
203
203
 
204
204
 
205
+ # ── Health Design B — ungated constant liveness (item 9.4) ───────────────────
206
+
207
+
208
+ @pytest.mark.anyio
209
+ async def test_ungated_body_constant_ok_when_degraded(test_router: APIRouter) -> None:
210
+ """Ungated /health always returns 200 ok even when a check is DEGRADED (9.4)."""
211
+
212
+ async def degraded() -> HealthCheckResult:
213
+ return HealthCheckResult(name="cache", status=HealthStatus.DEGRADED)
214
+
215
+ a = create_app(
216
+ make_settings(**_BASE),
217
+ test_router,
218
+ health=HealthConfig(checks=[degraded]),
219
+ )
220
+ async with live_client(a) as client:
221
+ resp = await client.get("/api/health/")
222
+ assert resp.status_code == 200
223
+ assert resp.json() == {"status": "ok"}
224
+
225
+
226
+ @pytest.mark.anyio
227
+ async def test_ungated_body_constant_ok_when_fail(test_router: APIRouter) -> None:
228
+ """Ungated /health always returns 200 ok even when a check is FAIL — no 503 leak (9.4)."""
229
+
230
+ async def bad() -> HealthCheckResult:
231
+ return HealthCheckResult(name="db", status=HealthStatus.FAIL)
232
+
233
+ a = create_app(
234
+ make_settings(**_BASE),
235
+ test_router,
236
+ health=HealthConfig(checks=[bad]),
237
+ )
238
+ async with live_client(a) as client:
239
+ resp = await client.get("/api/health/")
240
+ assert resp.status_code == 200
241
+ assert resp.json() == {"status": "ok"}
242
+
243
+
244
+ @pytest.mark.anyio
245
+ async def test_ungated_no_detail_keys_when_fail(test_router: APIRouter) -> None:
246
+ """Ungated FAIL response: only constant body, no checks/version keys leak (9.4)."""
247
+
248
+ async def bad() -> HealthCheckResult:
249
+ return HealthCheckResult(name="db", status=HealthStatus.FAIL)
250
+
251
+ a = create_app(
252
+ make_settings(**_BASE),
253
+ test_router,
254
+ health=HealthConfig(checks=[bad]),
255
+ )
256
+ async with live_client(a) as client:
257
+ resp = await client.get("/api/health/")
258
+ body = resp.json()
259
+ assert "checks" not in body
260
+ assert "fastapi_m8" not in body
261
+ assert body.get("status") == "ok"
262
+
263
+
264
+ @pytest.mark.anyio
265
+ async def test_gated_fail_returns_503_with_real_status(test_router: APIRouter) -> None:
266
+ """Credentialed /health with a FAIL check returns 503 and status=fail (9.4)."""
267
+
268
+ async def bad() -> HealthCheckResult:
269
+ return HealthCheckResult(name="db", status=HealthStatus.FAIL)
270
+
271
+ a = create_app(
272
+ make_settings(**_BASE, HEALTH_DETAIL_CREDENTIAL="health-cred"),
273
+ test_router,
274
+ health=HealthConfig(checks=[bad]),
275
+ )
276
+ async with live_client(a, headers={"X-Internal-Token": "health-cred"}) as client:
277
+ resp = await client.get("/api/health/")
278
+ assert resp.status_code == 503
279
+ body = resp.json()
280
+ assert body["status"] == "fail"
281
+ assert "checks" in body
282
+
283
+
284
+ @pytest.mark.anyio
285
+ async def test_gated_degraded_returns_real_status(test_router: APIRouter) -> None:
286
+ """Credentialed /health with a DEGRADED check shows the real status (9.4)."""
287
+
288
+ async def degraded() -> HealthCheckResult:
289
+ return HealthCheckResult(name="cache", status=HealthStatus.DEGRADED)
290
+
291
+ a = create_app(
292
+ make_settings(**_BASE, HEALTH_DETAIL_CREDENTIAL="health-cred"),
293
+ test_router,
294
+ health=HealthConfig(checks=[degraded], policy=HealthAggregatePolicy.LENIENT),
295
+ )
296
+ async with live_client(a, headers={"X-Internal-Token": "health-cred"}) as client:
297
+ resp = await client.get("/api/health/")
298
+ assert resp.status_code == 200
299
+ assert resp.json()["status"] == "degraded"
300
+
301
+
205
302
  # ── Health checks ─────────────────────────────────────────────────────────────
206
303
 
207
304
 
208
305
  @pytest.mark.anyio
209
- async def test_failing_check_returns_503(test_router: APIRouter) -> None:
210
- """A FAIL check drives the aggregate to 503."""
306
+ async def test_failing_check_gated_returns_503(test_router: APIRouter) -> None:
307
+ """A FAIL check drives the aggregate to 503 for credentialed (detail_public) callers."""
211
308
 
212
309
  async def bad() -> HealthCheckResult:
213
310
  return HealthCheckResult(name="db", status=HealthStatus.FAIL)
214
311
 
215
312
  a = create_app(
216
- make_settings(**_BASE), test_router, health=HealthConfig(checks=[bad])
313
+ make_settings(**_BASE),
314
+ test_router,
315
+ health=HealthConfig(checks=[bad], detail_public=True),
217
316
  )
218
317
  async with live_client(a) as client:
219
318
  resp = await client.get("/api/health/")
@@ -222,8 +321,8 @@ async def test_failing_check_returns_503(test_router: APIRouter) -> None:
222
321
 
223
322
 
224
323
  @pytest.mark.anyio
225
- async def test_degraded_check_lenient_returns_200(test_router: APIRouter) -> None:
226
- """A DEGRADED check stays at 200 under LENIENT policy."""
324
+ async def test_degraded_check_lenient_gated_returns_200(test_router: APIRouter) -> None:
325
+ """A DEGRADED check stays at 200 under LENIENT policy for credentialed callers."""
227
326
 
228
327
  async def degraded() -> HealthCheckResult:
229
328
  return HealthCheckResult(name="minio", status=HealthStatus.DEGRADED)
@@ -234,6 +333,7 @@ async def test_degraded_check_lenient_returns_200(test_router: APIRouter) -> Non
234
333
  health=HealthConfig(
235
334
  checks=[degraded],
236
335
  policy=HealthAggregatePolicy.LENIENT,
336
+ detail_public=True,
237
337
  ),
238
338
  )
239
339
  async with live_client(a) as client:
@@ -264,7 +364,7 @@ async def test_health_detail_public_exposes_checks(test_router: APIRouter) -> No
264
364
 
265
365
  @pytest.mark.anyio
266
366
  async def test_health_cache_prevents_double_invocation(test_router: APIRouter) -> None:
267
- """Within health_cache_ttl a second probe reuses cached results."""
367
+ """Within health_cache_ttl a second probe reuses cached results (detail_public path)."""
268
368
  call_count = 0
269
369
 
270
370
  async def counted() -> HealthCheckResult:
@@ -275,7 +375,7 @@ async def test_health_cache_prevents_double_invocation(test_router: APIRouter) -
275
375
  a = create_app(
276
376
  make_settings(**_BASE),
277
377
  test_router,
278
- health=HealthConfig(checks=[counted], cache_ttl=10.0),
378
+ health=HealthConfig(checks=[counted], cache_ttl=10.0, detail_public=True),
279
379
  )
280
380
  async with live_client(a) as client:
281
381
  await client.get("/api/health/")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes