fix: link project to its portal client (project.client_id) so the portal isn't empty
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.
This commit is contained in:
+10
-3
@@ -185,12 +185,16 @@ def provision_preview_session(project, db) -> str:
|
|||||||
|
|
||||||
# --- Phase-1 per-project password gate -------------------------------------------
|
# --- Phase-1 per-project password gate -------------------------------------------
|
||||||
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
||||||
# owning exactly that project, so the existing client-scoped routes are automatically
|
# owning exactly that project. The project is linked to it via project.client_id so
|
||||||
# per-project. Project.client_id is left untouched (deferred per-client rollup).
|
# 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:
|
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}"
|
slug = f"portal-{project.id}"
|
||||||
client = db.query(Client).filter_by(slug=slug).first()
|
client = db.query(Client).filter_by(slug=slug).first()
|
||||||
if client is None:
|
if client is None:
|
||||||
@@ -199,6 +203,9 @@ def portal_client_for_project(project, db) -> Client:
|
|||||||
slug=slug, active=True)
|
slug=slug, active=True)
|
||||||
db.add(client)
|
db.add(client)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
if project.client_id != client.id:
|
||||||
|
project.client_id = client.id # without this, the client owns no projects
|
||||||
|
db.flush()
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 isinstance(c1, Client) and c1.id == c2.id
|
||||||
assert c1.slug == f"portal-{p.id}"
|
assert c1.slug == f"portal-{p.id}"
|
||||||
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
|
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):
|
def test_mint_portal_session_returns_usable_token_id(db_session):
|
||||||
|
|||||||
@@ -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
|
# Try to open B's location page → 404 (not 403), no leak
|
||||||
r2 = client.get(f"/portal/location/{b_loc.id}")
|
r2 = client.get(f"/portal/location/{b_loc.id}")
|
||||||
assert r2.status_code == 404
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user