"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3). The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus the operator's S3 visibility requirements (a collection settable public/hidden; hidden = visible to project/global scope contributors but not the public; a collection's visibility may be set only as strict or stricter than its project). The §B.2 resolver folds four layers — global → project → collection → per-entry — most-permissively, with no negative override. The scenarios below are exercised directly against the resolver/gate helpers, and the visibility ones additionally through the HTTP surface. Background (C.1): a deployment with a project "ohm" owning collections "model" (document) and "features" (bdd); a second project "acme" with collection "specs". Plus a hidden ("gated") collection "secret" under ohm for the hidden-from-public scenarios. """ 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 # --------------------------------------------------------------------------- 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 _seed_world() -> None: """The C.1 background plus a hidden collection and a second project.""" _project("ohm", "public") _collection("ohm", "ohm", subfolder="") # ohm's structural default _collection("model", "ohm", ctype="document") _collection("features", "ohm", ctype="bdd") _collection("secret", "ohm", visibility="gated") # hidden from public _project("acme", "public") _collection("specs", "acme", ctype="specification") # the cast for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"), (5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]: provision_user_row(user_id=uid, login=login, role="contributor") _grant("collection", "model", 1, "contributor") # ada _grant("project", "ohm", 2, "contributor") # ben _grant("global", "*", 3, "contributor") # cleo _grant("collection", "features", 4, "owner") # dan _grant("project", "ohm", 5, "owner") # eve _grant("collection", "model", 6, "contributor") # fay (+ project owner below) _grant("project", "ohm", 6, "owner") # fay _grant("project", "ohm", 7, "contributor") # gil # hana (8): no grant. # --------------------------------------------------------------------------- # C.1 — role usage: inheritance and the most-permissive union # --------------------------------------------------------------------------- def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() ada = _su(1, "ada") # may submit a new entry in ohm/model assert auth.can_contribute_in_collection(ada, "model") is True # ohm/features is read-only and propose is not offered assert auth.can_read_collection(ada, "features") is True assert auth.can_contribute_in_collection(ada, "features") is False def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() ben = _su(2, "ben") assert auth.can_contribute_in_collection(ben, "model") is True assert auth.can_contribute_in_collection(ben, "features") is True # a collection added later is writable with no new grant _collection("roadmap", "ohm", ctype="document") assert auth.can_contribute_in_collection(ben, "roadmap") is True def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() cleo = _su(3, "cleo") assert auth.can_contribute_in_collection(cleo, "model") is True assert auth.can_contribute_in_collection(cleo, "specs") is True # acme def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() dan = _su(4, "dan") # graduate / mark-reviewed / manage membership in ohm/features assert auth.is_collection_superuser(dan, "features") is True # but not change ohm project settings assert auth.is_project_superuser(dan, "ohm") is False assert auth.can_create_collection(dan, "ohm") is False # and not act on entries in ohm/model assert auth.is_collection_superuser(dan, "model") is False assert auth.can_contribute_in_collection(dan, "model") is False def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() eve = _su(5, "eve") assert auth.is_collection_superuser(eve, "model") is True assert auth.is_collection_superuser(eve, "features") is True assert auth.is_project_superuser(eve, "ohm") is True # edit project settings assert auth.can_create_collection(eve, "ohm") is True # create a new collection def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() fay = _su(6, "fay") # collection RFC Contributor at model + project Owner at ohm → acts as Owner in model assert auth.effective_scope_role(fay, "model") == "owner" assert auth.is_collection_superuser(fay, "model") is True def test_c1_7_no_negative_override(app_with_fake_gitea): from app import auth, db app, _ = app_with_fake_gitea with TestClient(app): _seed_world() gil = _su(7, "gil") # gil can propose in ohm/model via the project grant… assert auth.can_contribute_in_collection(gil, "model") is True # …and there is no collection-scope row to remove at model while keeping # the project grant (a child cannot subtract a parent grant). row = db.conn().execute( "SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'" ).fetchone() assert row is None def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): _seed_world() hana = _su(8, "hana") # may read public collections assert auth.can_read_collection(hana, "model") is True # but is not offered the propose action anywhere (no scope role; the # grandfathered baseline covers only the N=1 `default` collection) assert auth.can_contribute_in_collection(hana, "model") is False assert auth.can_contribute_in_collection(hana, "features") is False assert auth.can_contribute_in_collection(hana, "specs") is False # gated (hidden) collections do not appear for her assert auth.can_read_collection(hana, "secret") is False # --------------------------------------------------------------------------- # Collection-grain visibility — the operator's S3 requirements # --------------------------------------------------------------------------- def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea): """A gated collection is omitted from the directory and 404s on read for the public, yet is listed + readable for a scope-role contributor.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _seed_world() # anonymous: the gated 'secret' collection is not listed, and 404s. listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} assert "secret" not in listed assert "model" in listed # public ones still listed assert client.get("/api/projects/ohm/collections/secret").status_code == 404 assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404 # ben (project contributor) sees and reads it. sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor") listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} assert "secret" in listed2 assert client.get("/api/projects/ohm/collections/secret").status_code == 200 assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200 # hana (granted, no role) is back to the public view. sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor") listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} assert "secret" not in listed3 assert client.get("/api/projects/ohm/collections/secret").status_code == 404 def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea): """A collection may be created only as strict or stricter than its project; a looser request is refused (422). On a gated project, a 'public' collection is rejected.""" app, _ = app_with_fake_gitea with TestClient(app) as client: _project("locked", "gated") _collection("locked", "locked", subfolder="", visibility="gated") # eve is a deployment owner here to clear the create-authority gate; # the strictness check fires regardless. provision_user_row(user_id=9, login="root", role="owner") sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner") r = client.post("/api/projects/locked/collections", json={ "collection_id": "wideopen", "type": "document", "visibility": "public", }) assert r.status_code == 422, r.text assert "looser" in r.json()["detail"] def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea): """§B.1: a project-scope Owner may create a collection; a plain granted contributor with no project/global grant may not (403).""" app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_world() # gil is only a project *contributor* on ohm — per §B.1 a project-scope # contributor CAN create collections (the project-level create # affordance). A collection-scope grant cannot. sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor") r_ok = client.post("/api/projects/ohm/collections", json={ "collection_id": "fromgil", "type": "document", "visibility": "public", }) assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate # ada holds only a *collection*-scope grant (at model) — no create right. sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor") r_no = client.post("/api/projects/ohm/collections", json={ "collection_id": "fromada", "type": "document", "visibility": "public", }) assert r_no.status_code == 403, r_no.text