"""Slice S4 — invitation surfaces + role-aware empty states (@S4). The acceptance gate for S4 is "every Part C.2 invitation scenario passes" (the design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.2, tagged @S4), plus the capability flags that drive the C.3 (@S4) role-aware empty states. An Owner grants {owner, contributor} at a scope their reach covers — the project, or a single collection within it — to an existing account looked up by email. The grant writes a `memberships` row immediately and §15-notifies the grantee (no accept round-trip). Reach is bounded by the inviter's Owner reach; re-granting at a broader scope supersedes the narrower row; a `pending` deployment account's grant is recorded but confers no write. Background (C.2): project "ohm" owns collections "model" (document) and "features" (bdd). eve is project Owner of ohm; dan is collection Owner of features only; ben is a project Contributor of ohm. ivy / jo / jet are grantees; kim is a pending deployment account. """ from __future__ import annotations from fastapi.testclient import TestClient from test_propose_vertical import ( # noqa: F401 — fixtures land via import app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) # --------------------------------------------------------------------------- # Helpers (mirror the S3 vertical's world-builders) # --------------------------------------------------------------------------- def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"): from app import auth return auth.SessionUser( user_id=user_id, gitea_id=user_id, gitea_login=login, display_name=login.capitalize(), email=f"{login}@test", avatar_url="", role=role, permission_state=state, ) def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None: from app import db db.conn().execute( "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " "VALUES (?, ?, ?, ?, datetime('now'))", (pid, pid.capitalize(), content_repo, visibility), ) def _collection(cid: str, project_id: str, *, ctype: str = "document", visibility: str = "public", subfolder: str | None = None) -> None: from app import db db.conn().execute( "INSERT OR REPLACE INTO collections " "(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) " "VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))", (cid, project_id, ctype, subfolder if subfolder is not None else cid, visibility, cid.capitalize()), ) def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None: from app import db db.conn().execute( "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " "VALUES (?, ?, ?, ?)", (scope_type, scope_id, user_id, role), ) def _membership(user_id: int): """The set of (scope_type, scope_id, role) rows a user holds.""" from app import db rows = db.conn().execute( "SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?", (user_id,), ).fetchall() return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows} def _seed_world() -> None: _project("ohm", "public") _collection("model", "ohm", ctype="document") _collection("features", "ohm", ctype="bdd") # the cast provision_user_row(user_id=2, login="ben", role="contributor") provision_user_row(user_id=4, login="dan", role="contributor") provision_user_row(user_id=5, login="eve", role="contributor") provision_user_row(user_id=10, login="ivy", role="contributor") provision_user_row(user_id=11, login="jo", role="contributor") provision_user_row(user_id=12, login="jet", role="contributor") provision_user_row(user_id=13, login="kim", role="contributor") _grant("project", "ohm", 5, "owner") # eve — project Owner _grant("collection", "features", 4, "owner") # dan — collection Owner only _grant("project", "ohm", 2, "contributor") # ben — project Contributor # kim is a pending deployment account. from app import db db.conn().execute( "UPDATE users SET permission_state = 'pending' WHERE id = 13" ) def _login_eve(client) -> None: sign_in_as(client, user_id=5, gitea_login="eve", display_name="Eve", role="contributor") # --------------------------------------------------------------------------- # C.2 — invitation: who may invite whom, at which scope # --------------------------------------------------------------------------- def test_c2_1_project_owner_invites_at_project_scope(app_with_fake_gitea): """A project Owner grants at project scope; the grant covers every collection, and the grantee is §15-notified naming the project and role.""" from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _login_eve(client) r = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor"}) assert r.status_code == 200, r.text # a membership row is written at scope project "ohm" assert ("project", "ohm", "contributor") in _membership(10) # ivy may propose in every collection of "ohm" ivy = _su(10, "ivy") assert auth.can_contribute_in_collection(ivy, "model") is True assert auth.can_contribute_in_collection(ivy, "features") is True # ivy receives a §15 notification naming the project and role sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor") inbox = client.get("/api/notifications").json()["items"] granted = [n for n in inbox if n["event_kind"] == "scope_role_granted"] assert granted, inbox assert "Ohm" in granted[0]["summary"] assert "RFC Contributor" in granted[0]["summary"] def test_c2_2_owner_invites_at_specific_collection(app_with_fake_gitea): """A grant at a single collection scope reaches that collection only.""" from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _login_eve(client) r = client.post("/api/projects/ohm/members", json={"email": "jo@test", "role": "contributor", "collection_id": "features"}) assert r.status_code == 200, r.text assert ("collection", "features", "contributor") in _membership(11) jo = _su(11, "jo") assert auth.can_contribute_in_collection(jo, "features") is True assert auth.can_contribute_in_collection(jo, "model") is False def test_c2_3_invitation_reach_bounded_by_inviter_scope(app_with_fake_gitea): """A collection Owner may invite within that collection, but is not offered (is refused) the control to invite at the project or globally.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() # dan is Owner at collection "features" only. sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor") # may grant at his collection ok = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor", "collection_id": "features"}) assert ok.status_code == 200, ok.text # but not at the project scope no = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor"}) assert no.status_code == 403, no.text # the capability flags the UI reads agree: no project invite, yes collection proj = client.get("/api/projects/ohm/collections").json()["viewer"] assert proj["can_invite"] is False col = client.get("/api/projects/ohm/collections/features").json()["viewer"] assert col["can_invite"] is True def test_c2_4_contributors_do_not_manage_membership(app_with_fake_gitea): """An RFC Contributor (project- or collection-scoped) holds no invite capability and the grant endpoints refuse them.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() # ben is a project *Contributor* on ohm. sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor") no_proj = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor"}) assert no_proj.status_code == 403, no_proj.text no_col = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor", "collection_id": "features"}) assert no_col.status_code == 403, no_col.text # no invite control surfaced anywhere assert client.get("/api/projects/ohm/collections").json()["viewer"]["can_invite"] is False assert client.get("/api/projects/ohm/collections/features").json()["viewer"]["can_invite"] is False def test_c2_5_no_grant_at_parent_revoke_at_child_option(app_with_fake_gitea): """The grant surface offers only role + scope (project or one collection); there is no way to grant at the project yet carve out a child collection — a project grant reaches every collection, full stop.""" from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _login_eve(client) # the only knobs are role and an optional single collection_id; an # "exclude" field has no effect (it is not part of the contract). r = client.post("/api/projects/ohm/members", json={"email": "ivy@test", "role": "contributor", "exclude_collection_id": "features"}) assert r.status_code == 200, r.text # the project grant still reaches the supposedly-excluded collection ivy = _su(10, "ivy") assert auth.can_contribute_in_collection(ivy, "features") is True def test_c2_6_broader_scope_supersedes_narrower(app_with_fake_gitea): """Re-granting at a broader scope removes the subsumed narrower row; the grantee holds the role across the whole project.""" from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _grant("collection", "features", 12, "contributor") # jet starts narrow _login_eve(client) r = client.post("/api/projects/ohm/members", json={"email": "jet@test", "role": "contributor"}) assert r.status_code == 200, r.text rows = _membership(12) # the project grant is present… assert ("project", "ohm", "contributor") in rows # …and the redundant collection-scope row is gone (subsumed) assert ("collection", "features", "contributor") not in rows jet = _su(12, "jet") assert auth.can_contribute_in_collection(jet, "model") is True assert auth.can_contribute_in_collection(jet, "features") is True def test_c2_6b_narrower_stronger_role_is_not_subtracted(app_with_fake_gitea): """No negative override: a child Owner grant survives a parent Contributor grant (the stronger collection role is kept).""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _grant("collection", "features", 12, "owner") # jet is collection Owner _login_eve(client) r = client.post("/api/projects/ohm/members", json={"email": "jet@test", "role": "contributor"}) assert r.status_code == 200, r.text rows = _membership(12) assert ("project", "ohm", "contributor") in rows # the stronger collection-Owner row is NOT pruned by a weaker project grant assert ("collection", "features", "owner") in rows def test_c2_7_pending_account_grant_confers_no_write(app_with_fake_gitea): """A grant to a pending deployment account is recorded but confers no write until the account is granted at the deployment (§6).""" from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _login_eve(client) r = client.post("/api/projects/ohm/members", json={"email": "kim@test", "role": "contributor"}) assert r.status_code == 200, r.text assert r.json()["pending"] is True # the grant row is recorded… assert ("project", "ohm", "contributor") in _membership(13) # …but confers no write while pending (the §6 admission floor) kim = _su(13, "kim", state="pending") assert auth.effective_scope_role(kim, "model") is None assert auth.can_contribute_in_collection(kim, "model") is False # --------------------------------------------------------------------------- # C.3 (@S4) — the capability flags behind the role-aware empty states # --------------------------------------------------------------------------- def test_c3_3_project_owner_sees_create_first_collection_capability(app_with_fake_gitea): """C3.3: a project Owner landing on an empty project may create a collection — the flag the 'Create your first collection' CTA reads.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _login_eve(client) caps = client.get("/api/projects/ohm/collections").json()["viewer"] assert caps["can_create_collection"] is True assert caps["role"] == "owner" def test_c3_4_contributor_without_create_rights_has_no_create_capability(app_with_fake_gitea): """C3.4: a contributor whose only grant is at a collection elsewhere has no create-collection capability — the empty directory shows no create action.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() # dan holds only a collection-scope grant (features); no project create right. sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor") caps = client.get("/api/projects/ohm/collections").json()["viewer"] assert caps["can_create_collection"] is False def test_c3_5_collection_contributor_sees_propose_first_capability(app_with_fake_gitea): """C3.5: a collection contributor landing on an empty collection may propose — the flag the 'Propose the first entry' CTA reads; an anonymous reader may not (the sign-in prompt path, already shipped in S2).""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() _grant("collection", "model", 10, "contributor") # ivy contributes in model sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor") caps = client.get("/api/projects/ohm/collections/model").json()["viewer"] assert caps["can_contribute"] is True # anonymous reader: no propose capability client.cookies.clear() caps_anon = client.get("/api/projects/ohm/collections/model").json()["viewer"] assert caps_anon["can_contribute"] is False