From 2f5966d4b646aafa25d1ca8b15b26c205151f45c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:32:39 +0900 Subject: [PATCH 1/3] fix(api): route /healthz to the actual liveness probe, not settings Two @app.get decorators were stacked before read_tenant_settings: @app.get("/healthz") @app.get("/api/settings", response_model=dict) async def read_tenant_settings(...): Both bound to the same handler -- "/healthz" required authentication (read_tenant_settings depends on get_current_account) and the real healthz() function below had no route decorator at all, so it was dead code never reachable by any request. docker-compose.yml's own backend healthcheck hits "/healthz" with a plain unauthenticated urllib.request.urlopen call; against this bug it would receive 401/403, fail the healthcheck, and mark the container unhealthy on every fresh deployment. Move the decorator onto healthz() where it belongs. Also add migration 0103_tenant_settings.sql to backend/tests/test_api.py's seeded_db fixture -- it was never added when the migration shipped, so the tenant_settings table (and therefore the /api/settings GET/PATCH endpoints, both previously untested) didn't exist in the test schema at all. Tests: test_healthz_is_reachable_without_a_token (the regression this bug needed) plus three new /api/settings tests (GET returns the seeded brand name, PATCH requires post_admin, PATCH as admin actually changes it). uv run --frozen python -m pytest -q: 757 passed, 17 skipped. --- backend/app/main.py | 3 +-- backend/tests/test_api.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..5f310265a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -583,8 +583,6 @@ async def _post_filter_options( ) -@app.get("/healthz") - @app.get("/api/settings", response_model=dict) async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), @@ -614,6 +612,7 @@ async def update_tenant_settings( return {"brandName": brand_name} +@app.get("/healthz") async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..6605b0c5a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,9 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_TENANT_SETTINGS_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0103_tenant_settings.sql" +) def _postgres_available() -> bool: @@ -226,6 +229,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -1961,6 +1965,47 @@ def test_nonexistent_post_is_not_found(client, demo_analyst_token) -> None: assert response.status_code == 404 +def test_healthz_is_reachable_without_a_token(client) -> None: + # Live bug (2026-08-22): two @app.get decorators stacked before + # read_tenant_settings meant "/healthz" and "/api/settings" both routed + # to that auth-required handler, and the real healthz() below had no + # route at all -- the docker-compose backend healthcheck + # (urllib.request.urlopen against /healthz, no Authorization header) + # would have failed on every fresh deployment. + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_settings_get_returns_the_seeded_brand_name(client, demo_analyst_token, seeded_db) -> None: + response = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + assert response.json() == {"brandName": "LineageWeave"} + + +def test_update_settings_requires_post_admin(client, demo_analyst_token, seeded_db) -> None: + response = client.patch( + "/api/settings", + json={"brandName": "Someone Else's Brand"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_update_settings_as_admin_changes_the_brand_name(client, demo_analyst_token, seeded_db) -> None: + _grant_post_admin(seeded_db["dsn"]) + patch_response = client.patch( + "/api/settings", + json={"brandName": "Renamed Corp"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert patch_response.status_code == 200 + assert patch_response.json() == {"brandName": "Renamed Corp"} + + get_response = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert get_response.json() == {"brandName": "Renamed Corp"} + + def test_missing_token_is_unauthorized(client) -> None: response = client.get("/api/posts") assert response.status_code in (401, 403) From 6dce8af18a13b0bcfbcfc966d05db81f5687c0e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:31:54 +0900 Subject: [PATCH 2/3] fix(frontend): use OIDC return-url helpers and guard AdminPanel render Same shared-ancestor bug as #418/#415/#426/#427: the login button built an unsanitized returnUrl inline instead of returnUrlFromLocation()/ rememberOidcReturnUrl(), and AdminPanel's accessToken (string, required) was rendered from a string | undefined at both call sites. --- frontend/src/App.test.tsx | 3 +++ frontend/src/App.tsx | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..70eb27590 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -41,6 +41,9 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + // Persisted as a fallback in case the OIDC state round-trip is dropped + // (see oidcReturnUrl.ts's restoreOidcReturnUrl, consumed in main.tsx). + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toMatch(/^\//); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..6e52be55d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}