"""Slice M2 — project-scoped authorization + the §22.7 resolver. M1 laid the schema spine (the `projects` / `project_members` tables and the `project_id` column on every slug-bearing table). M2 builds the resolver on top: the most-permissive union of the deployment role (§6.1), the project role (§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on top (§22.7). Two operator decisions are pinned here as executable expectations: * implicit-on-public — a granted deployment `contributor` keeps its pre-multi-project write *baseline* on a `public` project (propose freely; an owned RFC's discuss/contribute still needs the per-RFC invite) with no project_members row. So the single public default project behaves exactly as it did before M2 (verified across the rest of the suite, and the `*_public_*` tests below). * preserve curation — the implicit-public baseline does NOT override per-RFC owner curation; only an *explicit* project_contributor/admin grant (or a deployment owner/admin) bypasses it. Everything is exercised on the single `default` project by flipping its visibility and granting/revoking `project_members` roles — the M2 slice is verifiable without a second project (which arrives with M3's registry mirror). """ 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, *, state: str = "granted"): """A SessionUser handle for direct resolver calls (the endpoint tests use sign_in_as instead).""" 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 _set_visibility(project_id: str, visibility: str) -> None: from app import db db.conn().execute( "UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id) ) def _add_member(project_id: str, user_id: int, role: str) -> None: from app import db db.conn().execute( "INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)", (project_id, user_id, role), ) def _remove_member(project_id: str, user_id: int) -> None: from app import db db.conn().execute( "DELETE FROM project_members WHERE project_id = ? AND user_id = ?", (project_id, user_id), ) def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None: """A minimal cached_rfcs row — enough for the authz gates (state, owners, project_id). project_id defaults to 'default' via migration 026 but we set it explicitly for clarity.""" import json from app import db db.conn().execute( """ INSERT OR REPLACE INTO cached_rfcs (slug, title, state, owners_json, arbiters_json, tags_json, project_id) VALUES (?, ?, ?, ?, '[]', '[]', ?) """, (slug, slug.capitalize(), state, json.dumps(owners or []), project_id), ) # --------------------------------------------------------------------------- # 1. The §22.7 resolver — tier composition # --------------------------------------------------------------------------- def test_resolver_public_project_tiers(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): provision_user_row(user_id=1, login="alice", role="contributor") provision_user_row(user_id=2, login="ben", role="owner") contributor = _su(1, "alice", "contributor") owner = _su(2, "ben", "owner") # default is public — read open to everyone incl. anonymous. assert auth.can_read_project(None, "default") is True assert auth.can_read_project(contributor, "default") is True # implicit-on-public: a granted deployment contributor carries the # write baseline without a project_members row. assert auth.can_contribute_in_project(contributor, "default") is True assert auth.can_discuss_in_project(contributor, "default") is True # ... but it is NOT project_admin (curation/override authority). assert auth.is_project_superuser(contributor, "default") is False # a deployment owner/admin is a superuser in every project. assert auth.is_project_superuser(owner, "default") is True # a pending contributor has no write standing (the §6 admission floor). pending = _su(1, "alice", "contributor", state="pending") assert auth.can_contribute_in_project(pending, "default") is False def test_resolver_gated_project_requires_membership(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): provision_user_row(user_id=1, login="alice", role="contributor") provision_user_row(user_id=2, login="ben", role="owner") _set_visibility("default", "gated") contributor = _su(1, "alice", "contributor") owner = _su(2, "ben", "owner") # gated: no membership → invisible and no standing. assert auth.can_read_project(None, "default") is False assert auth.can_read_project(contributor, "default") is False assert auth.can_contribute_in_project(contributor, "default") is False # deployment owner is a superuser regardless of membership. assert auth.can_read_project(owner, "default") is True assert auth.is_project_superuser(owner, "default") is True # project_viewer → read + discuss, but not contribute. _add_member("default", 1, "project_viewer") assert auth.can_read_project(contributor, "default") is True assert auth.can_discuss_in_project(contributor, "default") is True assert auth.can_contribute_in_project(contributor, "default") is False # project_contributor → contribute. _add_member("default", 1, "project_contributor") assert auth.can_contribute_in_project(contributor, "default") is True assert auth.is_project_superuser(contributor, "default") is False # project_admin → superuser within the project. _add_member("default", 1, "project_admin") assert auth.is_project_superuser(contributor, "default") is True # --------------------------------------------------------------------------- # 2. Public default unchanged — the regression floor (curation preserved) # --------------------------------------------------------------------------- def test_public_per_rfc_curation_preserved(app_with_fake_gitea): """On the public default project a granted contributor still cannot discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract); but an unclaimed (no-owners) entry stays open. This is the implicit-public baseline with curation preserved.""" from app import auth app, _ = app_with_fake_gitea with TestClient(app): provision_user_row(user_id=1, login="alice", role="contributor") provision_user_row(user_id=2, login="bob", role="contributor") bob = _su(2, "bob", "contributor") _seed_rfc("owned", state="active", owners=["alice"]) assert auth.can_discuss_rfc(bob, "owned") is False assert auth.can_contribute_to_rfc(bob, "owned") is False _seed_rfc("draft", state="super-draft", owners=[]) assert auth.can_discuss_rfc(bob, "draft") is True assert auth.can_contribute_to_rfc(bob, "draft") is True def test_public_read_open_write_gated_endpoints(app_with_fake_gitea): app, _ = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="bob", role="contributor") _seed_rfc("ohm", state="active", owners=["alice"]) # anonymous can read the entry on a public project. r = client.get("/api/rfcs/ohm") assert r.status_code == 200, r.text # anonymous cannot open a discussion thread (401, the §6 write floor). r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"}) assert r.status_code == 401 # --------------------------------------------------------------------------- # 3. The §22.5 visibility gate — 404 to non-members on read # --------------------------------------------------------------------------- def test_gated_project_404s_non_members(app_with_fake_gitea): app, _ = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="alice", role="contributor") provision_user_row(user_id=2, login="bob", role="contributor") provision_user_row(user_id=3, login="ben", role="owner") _seed_rfc("sekret", state="active", owners=["alice"]) _set_visibility("default", "gated") # anonymous → 404 (indistinguishable from an unknown slug). assert client.get("/api/rfcs/sekret").status_code == 404 # signed-in non-member → 404 on the entry and its discussion. sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") assert client.get("/api/rfcs/sekret").status_code == 404 assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404 # the gated entry never surfaces in the non-member's catalog. assert client.get("/api/rfcs").json()["items"] == [] # a project_viewer member can read again. _add_member("default", 2, "project_viewer") assert client.get("/api/rfcs/sekret").status_code == 200 assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"] # a deployment owner can always read. sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner") assert client.get("/api/rfcs/sekret").status_code == 200 # --------------------------------------------------------------------------- # 4. The contribution gate on a gated project (401/403) # --------------------------------------------------------------------------- def test_gated_propose_requires_project_contributor(app_with_fake_gitea): """Propose checks project-level contribute standing before any Gitea work, so the gate is observable as a 403 (gated, non-member) without seeding the success path.""" app, _ = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="bob", role="contributor") _set_visibility("default", "gated") sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []} assert client.post("/api/rfcs/propose", json=body).status_code == 403 # grant project_contributor — the project gate now passes (the request # proceeds past the gate; we assert only that it is no longer 403). _add_member("default", 2, "project_contributor") assert client.post("/api/rfcs/propose", json=body).status_code != 403 def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="bob", role="contributor") _seed_rfc("spec", state="super-draft", owners=[]) _set_visibility("default", "gated") bob = _su(2, "bob", "contributor") # non-member: discussion thread create → 404 (visibility, before the # write gate even applies). sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404 # project_viewer: can discuss (200) but cannot contribute (resolver). _add_member("default", 2, "project_viewer") r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}) assert r.status_code == 200, r.text assert auth.can_contribute_to_rfc(bob, "spec") is False # project_contributor: can contribute. _add_member("default", 2, "project_contributor") assert auth.can_contribute_to_rfc(bob, "spec") is True # --------------------------------------------------------------------------- # 5. Union of tiers + subtractive visibility gate # --------------------------------------------------------------------------- def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea): from app import auth from test_propose_vertical import grant_rfc_collaborator app, _ = app_with_fake_gitea with TestClient(app): provision_user_row(user_id=2, login="bob", role="contributor") _seed_rfc("owned", state="active", owners=["alice"]) bob = _su(2, "bob", "contributor") # public + per-RFC collaborator(contributor) → contribute (union term). grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor") assert auth.can_contribute_to_rfc(bob, "owned") is True # flip to gated: the §22.5 gate is subtractive — the per-RFC grant no # longer suffices without project membership. _set_visibility("default", "gated") assert auth.can_contribute_to_rfc(bob, "owned") is False # restore read via project membership → the per-RFC union applies again. _add_member("default", 2, "project_viewer") assert auth.can_contribute_to_rfc(bob, "owned") is True # --------------------------------------------------------------------------- # 6. Revocation takes effect on the next call # --------------------------------------------------------------------------- def test_revoking_membership_revokes_access(app_with_fake_gitea): from app import auth app, _ = app_with_fake_gitea with TestClient(app): provision_user_row(user_id=2, login="bob", role="contributor") _set_visibility("default", "gated") bob = _su(2, "bob", "contributor") _add_member("default", 2, "project_contributor") assert auth.can_contribute_in_project(bob, "default") is True _remove_member("default", 2) assert auth.can_read_project(bob, "default") is False assert auth.can_contribute_in_project(bob, "default") is False