Files
rfc-app/backend/tests/test_s4_invitations_vertical.py
T
Ben Stull c9fd1c535e §22 S4: scope-role invitation backend + capability flags (@S4 C.2)
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).

- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
  invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
  preserving a stronger child grant — no negative override), revoke, list,
  user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
  POST keying on optional collection_id so the invite UI's one control maps
  to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
  contributors refused (C.2.4); a pending grantee's row is recorded but
  confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
  notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
  can_invite, can_contribute, role) on the project/collection GETs to drive
  the C.3 role-aware empty states.

Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:47:28 -07:00

332 lines
15 KiB
Python

"""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