diff --git a/backend/app/api.py b/backend/app/api.py index 60be3f5..34cce3c 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -27,6 +27,7 @@ from . import ( api_discussion, api_graduation, api_invitations, + api_memberships, api_notifications, api_prs, auth, @@ -154,6 +155,9 @@ def make_router( # VITE_APP_NAME) + the old-URL 308 redirects. router.include_router(api_deployment.make_router(config)) router.include_router(api_collections.make_router(config, gitea, bot)) + # §22 S4 (C.2): the scope-role invitation surface — Owners grant + # {owner, contributor} at project/collection scope to existing accounts. + router.include_router(api_memberships.make_router()) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py index 2f990d4..61132ad 100644 --- a/backend/app/api_collections.py +++ b/backend/app/api_collections.py @@ -41,6 +41,26 @@ class CreateCollectionBody(BaseModel): initial_state: str | None = None +def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]: + """§22 S4: the viewer's project-grain capabilities for role-aware UI — may + they create a collection, may they manage membership (invite), and their + project role. `role` maps the §22.6 legacy strings back to the unified + `{owner, contributor}` vocabulary the frontend speaks.""" + legacy = auth.project_member_role(viewer, project_id) + role = None + if viewer is not None and viewer.role in ("owner", "admin"): + role = "owner" + elif legacy == "project_admin": + role = "owner" + elif legacy == "project_contributor": + role = "contributor" + return { + "can_create_collection": auth.can_create_collection(viewer, project_id), + "can_invite": auth.can_invite_at_project(viewer, project_id), + "role": role, + } + + def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: router = APIRouter() @@ -57,7 +77,10 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: for c in collections_mod.list_collections(project_id, include_unlisted=True) if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"]) ] - return {"items": items} + # §22 S4: surface the viewer's project-level capabilities so the + # directory can render role-aware affordances (the create-first- + # collection CTA, the invite control) without a second round-trip. + return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)} @router.get("/api/projects/{project_id}/collections/{collection_id}") async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: @@ -68,6 +91,14 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: raise HTTPException(404, "Not found") # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer. auth.require_collection_readable(viewer, collection_id) + # §22 S4: the viewer's collection-level capabilities drive the + # propose-first empty state and the collection invite control. + col = dict(col) + col["viewer"] = { + "can_contribute": auth.can_contribute_in_collection(viewer, collection_id), + "can_invite": auth.can_invite_at_collection(viewer, collection_id), + "role": auth.effective_scope_role(viewer, collection_id), + } return col @router.post("/api/projects/{project_id}/collections") diff --git a/backend/app/api_memberships.py b/backend/app/api_memberships.py new file mode 100644 index 0000000..296f448 --- /dev/null +++ b/backend/app/api_memberships.py @@ -0,0 +1,157 @@ +"""§22 S4 (C.2) — the scope-role invitation surface. + +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 (there is no accept round-trip; the C.2 scenarios name an existing +user and write the row directly). Endpoints: + + GET /api/projects/:pid/members — list the project subtree's grants + POST /api/projects/:pid/members — grant at project scope, or + (with collection_id) at one collection + DELETE /api/projects/:pid/members/:user_id — revoke (optionally ?collection_id=) + +The single POST keys on the optional `collection_id` so the invite UI's one +control (role picker + scope picker) maps to one endpoint: + + * no `collection_id` → project-scope grant; gate `can_invite_at_project`. + * with `collection_id` → collection-scope grant; gate `can_invite_at_collection`. + +There is deliberately no "grant at parent, exclude a child" parameter (C.2.5): +the only knobs are role ∈ {owner, contributor} and scope ∈ {project, one +collection}. Reach is bounded by the inviter's own Owner reach (C.2.3): a +collection Owner who is nothing more is refused the project-scope POST. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from . import ( + auth, + collections as collections_mod, + db, + memberships as memberships_mod, + notify, +) + + +class GrantBody(BaseModel): + email: str + role: str + collection_id: str | None = None + + +def _project_name(project_id: str) -> str | None: + row = db.conn().execute( + "SELECT name FROM projects WHERE id = ?", (project_id,) + ).fetchone() + return row["name"] if row and row["name"] else None + + +def make_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/projects/{project_id}/members") + async def list_members(project_id: str, request: Request) -> dict[str, Any]: + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + # The full subtree listing is a project-Owner view; a collection-only + # Owner manages membership through the collection-scoped POST/DELETE. + if not auth.can_invite_at_project(user, project_id): + raise HTTPException(403, "You may not manage membership in this project") + return {"items": memberships_mod.list_for_project(project_id)} + + @router.post("/api/projects/{project_id}/members") + async def grant_member( + project_id: str, body: GrantBody, request: Request + ) -> dict[str, Any]: + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + + role = (body.role or "").strip().lower() + if role not in memberships_mod.VALID_ROLES: + raise HTTPException(422, f"invalid role {body.role!r}") + + cid = (body.collection_id or "").strip() or None + if cid is not None: + # Collection-scope grant — bounded by Owner reach over that collection. + col = collections_mod.get_collection(cid) + if col is None or col["project_id"] != project_id: + raise HTTPException(404, "Not found") + if not auth.can_invite_at_collection(user, cid): + raise HTTPException(403, "You may not manage membership in this collection") + scope_type, scope_id = "collection", cid + else: + # Project-scope grant — bounded by Owner reach over the project. + if not auth.can_invite_at_project(user, project_id): + raise HTTPException(403, "You may not manage membership in this project") + scope_type, scope_id = "project", project_id + + grantee = memberships_mod.user_by_email(body.email) + if grantee is None: + raise HTTPException( + 404, + "No account with that email — the invitee must sign in to the " + "deployment before they can be granted a role", + ) + + memberships_mod.grant( + scope_type=scope_type, + scope_id=scope_id, + user_id=grantee["id"], + role=role, + granted_by=user.user_id, + ) + + # §15 (C.2): name the project and role to the grantee. + col_name = None + if scope_type == "collection": + col = collections_mod.get_collection(scope_id) + col_name = (col.get("name") if col else None) or scope_id + notify.notify_scope_role_granted( + recipient_user_id=grantee["id"], + granter_user_id=user.user_id, + scope_type=scope_type, + scope_id=scope_id, + role=role, + project_id=project_id, + project_name=_project_name(project_id), + collection_name=col_name, + ) + + return { + "scope_type": scope_type, + "scope_id": scope_id, + "user_id": grantee["id"], + "role": role, + "pending": grantee["permission_state"] != "granted", + } + + @router.delete("/api/projects/{project_id}/members/{user_id}") + async def revoke_member( + project_id: str, user_id: int, request: Request + ) -> dict[str, Any]: + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + cid = (request.query_params.get("collection_id") or "").strip() or None + if cid is not None: + col = collections_mod.get_collection(cid) + if col is None or col["project_id"] != project_id: + raise HTTPException(404, "Not found") + if not auth.can_invite_at_collection(user, cid): + raise HTTPException(403, "You may not manage membership in this collection") + removed = memberships_mod.revoke( + scope_type="collection", scope_id=cid, user_id=user_id + ) + else: + if not auth.can_invite_at_project(user, project_id): + raise HTTPException(403, "You may not manage membership in this project") + removed = memberships_mod.revoke( + scope_type="project", scope_id=project_id, user_id=user_id + ) + return {"removed": removed} + + return router diff --git a/backend/app/auth.py b/backend/app/auth.py index 4f529ad..30e4b5d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -682,6 +682,26 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool: return row is not None +def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool: + """§22 S4 (C.2): may the user grant scope roles at this project (or at any + collection within it)? Managing membership is an *Owner* capability whose + reach covers the project — a deployment owner/admin, a global Owner, or this + project's Owner. An RFC Contributor does not manage membership (C.2.4); a + collection Owner's reach is its own collection only (C.2.3), so it is not + offered project-scope invites. Identical to `is_project_superuser` — the + invite gate IS "is an Owner over this project".""" + return is_project_superuser(user, project_id) + + +def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool: + """§22 S4 (C.2): may the user grant scope roles at this collection? An Owner + whose reach covers it — the collection's Owner, its project's Owner, a global + Owner, or a deployment owner/admin (`is_collection_superuser`). This is the + narrowest invite reach; a collection Owner who is nothing more may invite + here but not at the project or globally (C.2.3).""" + return is_collection_superuser(user, collection_id) + + # v0.16.0 (roadmap item #12): per-RFC membership helpers. # # These don't replace `require_contributor` — they layer on top of it for diff --git a/backend/app/memberships.py b/backend/app/memberships.py new file mode 100644 index 0000000..aaba48c --- /dev/null +++ b/backend/app/memberships.py @@ -0,0 +1,141 @@ +"""§22 S4 (C.2) — scope-role grant operations over the `memberships` table. + +The membership *gates* (who may invite, who holds which role) live in +`auth.py`; this module holds the *mutations* the invitation surface drives — +granting, the "broader scope supersedes narrower" cleanup, listing, and +revocation — mirroring how `invites.py` owns the create/claim/list of per-user +invite tokens while the gate (`auth.can_invite_to_rfc`) lives in `auth.py`. + +The model (Part B / S3): a `memberships` row is `(scope_type ∈ {global, +project, collection}, scope_id, user_id, role ∈ {owner, contributor})`, unique +per `(scope_type, scope_id, user_id)`. A grant is a direct write of that row +(the C.2 scenarios write the row immediately and §15-notify an existing +account — there is no accept round-trip; inviting a not-yet-account email is +out of S4 scope and handled by the admin-create-invite path). + +The "broader scope supersedes narrower" rule (C.2.6): granting a role at a +broader scope removes this user's narrower rows that the new grant *subsumes* +— a narrower row whose role is no more permissive than the new one. A narrower +row that is *more* permissive is kept (no negative override: a child Owner +grant survives a parent Contributor grant, and the §B.2 resolver still unions +most-permissively). +""" +from __future__ import annotations + +from typing import Any + +from . import collections as collections_mod +from . import db + +# Higher rank = more permissive. Used by the supersede rule: a narrower grant +# is pruned only when its rank ≤ the new broader grant's rank. +_ROLE_RANK = {"contributor": 1, "owner": 2} + +VALID_SCOPE_TYPES = ("global", "project", "collection") +VALID_ROLES = ("owner", "contributor") +GLOBAL_SCOPE_ID = "*" + + +def user_by_email(email: str) -> dict[str, Any] | None: + """The `users` row (id, display_name, email, permission_state) for an + email, case-insensitively, or None. The grantee must already be an account + — S4 grants a scope role to an existing user, it does not provision one.""" + row = db.conn().execute( + "SELECT id, display_name, email, permission_state, role " + "FROM users WHERE lower(email) = lower(?) " + "ORDER BY id LIMIT 1", + (email.strip(),), + ).fetchone() + return dict(row) if row else None + + +def grant( + *, + scope_type: str, + scope_id: str, + user_id: int, + role: str, + granted_by: int | None, +) -> None: + """Write (or update) the membership row, then apply the C.2.6 + broader-scope-supersedes cleanup. Idempotent on `(scope_type, scope_id, + user_id)` — re-granting at the same scope updates the role and the grantor. + + The grant is recorded regardless of the grantee's deployment + `permission_state`: a `pending` account's row is written (C.2.7), but the + §6 admission floor in `auth.effective_scope_role` keeps it conferring no + write until the account is granted at the deployment.""" + db.conn().execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT (scope_type, scope_id, user_id) " + "DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, " + "granted_at = datetime('now')", + (scope_type, scope_id, user_id, role, granted_by), + ) + _prune_subsumed(scope_type=scope_type, scope_id=scope_id, user_id=user_id, role=role) + + +def _prune_subsumed(*, scope_type: str, scope_id: str, user_id: int, role: str) -> None: + """Remove this user's narrower rows that the just-written broader grant + subsumes (same-or-lower role rank within the broader scope's subtree). A + collection grant subsumes nothing narrower (the per-entry tier is separate); + a project grant subsumes its collections; a global grant subsumes every + project and collection.""" + rank = _ROLE_RANK[role] + keep_ranks = [r for r, v in _ROLE_RANK.items() if v <= rank] + if not keep_ranks: + return + placeholders = ",".join("?" for _ in keep_ranks) + if scope_type == "project": + # Narrower = collection-scope rows for collections in this project. + db.conn().execute( + f"DELETE FROM memberships " + f"WHERE user_id = ? AND scope_type = 'collection' " + f" AND role IN ({placeholders}) " + f" AND scope_id IN (SELECT id FROM collections WHERE project_id = ?)", + (user_id, *keep_ranks, scope_id), + ) + elif scope_type == "global": + # Narrower = every project- and collection-scope row for this user. + db.conn().execute( + f"DELETE FROM memberships " + f"WHERE user_id = ? AND scope_type IN ('project', 'collection') " + f" AND role IN ({placeholders})", + (user_id, *keep_ranks), + ) + + +def revoke(*, scope_type: str, scope_id: str, user_id: int) -> bool: + """Remove a membership row at exactly this scope. Returns True if a row was + removed. Revocation is scope-exact: it does not cascade to broader or + narrower grants (each is its own administrative act).""" + cur = db.conn().execute( + "DELETE FROM memberships WHERE scope_type = ? AND scope_id = ? AND user_id = ?", + (scope_type, scope_id, user_id), + ) + return cur.rowcount > 0 + + +def list_for_project(project_id: str) -> list[dict[str, Any]]: + """Every project-scope grant on this project plus every collection-scope + grant on its collections, joined to the grantee's display fields — the data + behind the project-Owner membership panel. Ordered project grants first, + then by collection, then by role (Owner before Contributor).""" + rows = db.conn().execute( + """ + SELECT m.scope_type, m.scope_id, m.user_id, m.role, m.granted_at, + u.display_name, u.email, u.permission_state, + c.name AS collection_name + FROM memberships m + JOIN users u ON u.id = m.user_id + LEFT JOIN collections c ON c.id = m.scope_id AND m.scope_type = 'collection' + WHERE (m.scope_type = 'project' AND m.scope_id = ?) + OR (m.scope_type = 'collection' + AND m.scope_id IN (SELECT id FROM collections WHERE project_id = ?)) + ORDER BY (m.scope_type != 'project'), m.scope_id, + CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, u.display_name + """, + (project_id, project_id), + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/app/notify.py b/backend/app/notify.py index 55a9a93..4427158 100644 --- a/backend/app/notify.py +++ b/backend/app/notify.py @@ -324,6 +324,48 @@ def fan_out_contribution_request( return notif_ids +def notify_scope_role_granted( + *, + recipient_user_id: int, + granter_user_id: int | None, + scope_type: str, + scope_id: str, + role: str, + project_id: str | None, + project_name: str | None, + collection_name: str | None, +) -> int | None: + """§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope. + Personal-direct — the recipient is the named subject — so it rides the + `email_personal_direct` gate like the other owner-facing personal events. + The scope facts ride in the payload so the inbox row (and email body) names + the project and role without a second fetch. Actor is the granter (§15.9); + a system/administrative grant with no granter renders as "the app". + + Returns the notification id, or None when the grantee would be notifying + themselves (a self-grant — no notification).""" + if granter_user_id is not None and recipient_user_id == granter_user_id: + return None + details = { + "scope_type": scope_type, + "scope_id": scope_id, + "role": role, + "project_id": project_id or "", + "project_name": project_name or "", + "collection_name": collection_name or "", + } + return _emit_one( + recipient_user_id=recipient_user_id, + event_kind="scope_role_granted", + category=CATEGORY_PERSONAL, + actor_user_id=granter_user_id, + rfc_slug=None, + branch_name=None, + pr_number=None, + details=details, + ) + + def notify_contribution_decided( *, rfc_slug: str, @@ -859,6 +901,19 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation." if event_kind == "contribution_request_declined": return f"{actor} declined your request to contribute to {title}." + if event_kind == "scope_role_granted": + # §22 S4 (C.2): names the role and the scope (the project, and the + # collection when collection-scoped) per "a §15 notification naming the + # project and role". + role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor" + project_label = extras.get("project_name") or extras.get("project_id") or "a project" + scope_type = extras.get("scope_type") + if scope_type == "collection": + col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection" + return f"{actor} granted you {role_label} on collection {project_label}/{col_label}." + if scope_type == "global": + return f"{actor} granted you {role_label} across the whole deployment." + return f"{actor} granted you {role_label} on project {project_label}." if event_kind == "new_beta_request": # v0.9.0: framework-scoped, not RFC-scoped. The actor (the # requester) and the captured full name + email read as diff --git a/backend/tests/test_s4_invitations_vertical.py b/backend/tests/test_s4_invitations_vertical.py new file mode 100644 index 0000000..91e214a --- /dev/null +++ b/backend/tests/test_s4_invitations_vertical.py @@ -0,0 +1,331 @@ +"""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