From b8e4718318ecb6919097d1c4497e89eea8dd09ba Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 15 Jun 2026 23:53:19 +0000 Subject: [PATCH] fix: link project to its portal client (project.client_id) so the portal isn't empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by adversarial review of the scope test: portal_client_for_project minted a dedicated client but never set project.client_id, so the client-scoped routes found no projects — every location 404'd, including the client's own (empty portal). Now links the project + adds a positive-case test. --- backend/portal_auth.py | 13 ++++++++++--- tests/test_portal_auth_helpers.py | 2 ++ tests/test_portal_scope.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/portal_auth.py b/backend/portal_auth.py index 21736f5..f87d21c 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -185,12 +185,16 @@ def provision_preview_session(project, db) -> str: # --- Phase-1 per-project password gate ------------------------------------------- # A portal-enabled project gets its OWN dedicated client (slug "portal-") -# owning exactly that project, so the existing client-scoped routes are automatically -# per-project. Project.client_id is left untouched (deferred per-client rollup). +# owning exactly that project. The project is linked to it via project.client_id so +# the existing client-scoped routes (which resolve projects by Project.client_id == +# client.id) surface exactly this one project for the portal session — per-project +# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a +# real per-client model is the deferred multi-tenant work.) def portal_client_for_project(project, db) -> Client: - """Get-or-create the dedicated 1:1 portal client for a project.""" + """Get-or-create the dedicated 1:1 portal client for a project, and link the + project to it so the client-scoped routes resolve exactly this project.""" slug = f"portal-{project.id}" client = db.query(Client).filter_by(slug=slug).first() if client is None: @@ -199,6 +203,9 @@ def portal_client_for_project(project, db) -> Client: slug=slug, active=True) db.add(client) db.flush() + if project.client_id != client.id: + project.client_id = client.id # without this, the client owns no projects + db.flush() return client diff --git a/tests/test_portal_auth_helpers.py b/tests/test_portal_auth_helpers.py index e37fa45..71eadda 100644 --- a/tests/test_portal_auth_helpers.py +++ b/tests/test_portal_auth_helpers.py @@ -11,6 +11,8 @@ def test_portal_client_for_project_is_1to1_and_idempotent(db_session): assert isinstance(c1, Client) and c1.id == c2.id assert c1.slug == f"portal-{p.id}" assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1 + # the project must be linked to its portal client, or client-scoped routes find nothing + assert p.client_id == c1.id def test_mint_portal_session_returns_usable_token_id(db_session): diff --git a/tests/test_portal_scope.py b/tests/test_portal_scope.py index cd52268..0d254fc 100644 --- a/tests/test_portal_scope.py +++ b/tests/test_portal_scope.py @@ -29,3 +29,15 @@ def test_session_for_A_cannot_open_B_location(client, db_session): # Try to open B's location page → 404 (not 403), no leak r2 = client.get(f"/portal/location/{b_loc.id}") assert r2.status_code == 404 + + +def test_session_can_open_its_own_location(client, db_session): + # Positive case: proves the negative test's 404 is real scoping, not a blanket + # "client owns nothing" failure — an A session CAN open A's own location. + a = make_project(db_session, portal_enabled=True, portal_link_token="ta2", + portal_password_hash=hash_password("pw")) + a_loc = _sound_location(db_session, a) + r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False) + assert r.status_code == 303 + r2 = client.get(f"/portal/location/{a_loc.id}") + assert r2.status_code == 200