c9fd1c535e
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>
332 lines
15 KiB
Python
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
|