fcc3c84d76
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.
Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
requester, requested_role, message, status, granted_role); one-open-per
(scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
{id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
Accept grants via memberships.grant; the request POST does not require the
scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
_scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.
Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
collection directory + catalog footer; JoinRequestRow in the inbox.
Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.
Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
14 KiB
Python
340 lines
14 KiB
Python
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
|
|
|
A user who knows a (gated) scope exists asks to join it, naming a desired role;
|
|
the request is recorded and fanned out to that scope's Owners *across the
|
|
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
|
|
the `memberships` row via memberships.grant — or declines, and the requester is
|
|
§15-notified either way.
|
|
|
|
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
|
|
and test_s4_invitations_vertical.py (the scope/membership world-builders).
|
|
|
|
World: project "ohm" owns collections "model" (document, gated) and "features"
|
|
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
|
|
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
|
|
(no scope role) — the would-be joiner.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import db
|
|
|
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
|
app_with_fake_gitea,
|
|
provision_user_row,
|
|
sign_in_as,
|
|
tmp_env,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# World-builders (mirror the S4 vertical)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
|
|
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 = "gated") -> None:
|
|
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, cid, visibility, cid.capitalize()),
|
|
)
|
|
|
|
|
|
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
|
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):
|
|
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 _join_requests(scope_type: str, scope_id: str):
|
|
rows = db.conn().execute(
|
|
"SELECT id, requester_user_id, requested_role, status, granted_role "
|
|
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
|
|
(scope_type, scope_id),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
|
|
return {
|
|
r["recipient_user_id"]
|
|
for r in db.conn().execute(
|
|
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
|
|
(event_kind,),
|
|
)
|
|
}
|
|
|
|
|
|
def _seed_world() -> None:
|
|
_project("ohm", "gated")
|
|
_collection("model", "ohm", ctype="document")
|
|
_collection("features", "ohm", ctype="bdd")
|
|
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
|
|
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
|
|
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
|
|
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
|
|
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
|
|
_grant("project", "ohm", 5, "owner")
|
|
_grant("collection", "features", 4, "owner")
|
|
_grant("global", "*", 6, "owner")
|
|
|
|
|
|
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
|
|
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request → cross-collection fan-out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
|
|
"""A request to join a collection lands a row and notifies every Owner whose
|
|
reach covers it — the collection's Owner, the project's Owner, a global
|
|
Owner, and the deployment admin (the cross-collection inbox) — never the
|
|
requester."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
r = client.post(
|
|
"/api/scopes/collection/features/join-requests",
|
|
json={"role": "contributor", "message": "I work on BDD corpora."},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["status"] == "pending"
|
|
|
|
reqs = _join_requests("collection", "features")
|
|
assert len(reqs) == 1
|
|
assert reqs[0]["requester_user_id"] == 2
|
|
assert reqs[0]["requested_role"] == "contributor"
|
|
assert reqs[0]["status"] == "pending"
|
|
|
|
# Owners across the subtree are notified; ben (requester) is not.
|
|
recips = _join_notif_recipients()
|
|
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
|
|
assert 2 not in recips
|
|
|
|
|
|
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
|
|
"""A project-scope request reaches the project's Owners + global Owners +
|
|
admin, but NOT a collection-only Owner (their reach doesn't cover the
|
|
project)."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
r = client.post(
|
|
"/api/scopes/project/ohm/join-requests",
|
|
json={"role": "owner"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
recips = _join_notif_recipients()
|
|
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
|
|
assert 4 not in recips # dan is only a collection Owner
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Accept → writes membership + notifies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
|
|
"""The collection Owner accepts; a `memberships` row is written at the
|
|
requested scope/role and the requester gets a join_request_accepted inbox
|
|
row."""
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
client.post(
|
|
"/api/scopes/collection/features/join-requests",
|
|
json={"role": "contributor"},
|
|
)
|
|
req_id = _join_requests("collection", "features")[0]["id"]
|
|
|
|
# dan (collection Owner of features) accepts.
|
|
_login(client, 4, "dan")
|
|
r = client.post(
|
|
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
|
json={},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["granted_role"] == "contributor"
|
|
|
|
# ben now holds the collection role and can contribute there.
|
|
assert ("collection", "features", "contributor") in _membership(2)
|
|
ben = auth.SessionUser(
|
|
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
|
|
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
|
|
)
|
|
assert auth.can_contribute_in_collection(ben, "features") is True
|
|
assert auth.can_contribute_in_collection(ben, "model") is False
|
|
|
|
# the row is closed; the requester is notified.
|
|
assert _join_requests("collection", "features")[0]["status"] == "accepted"
|
|
_login(client, 2, "ben")
|
|
inbox = client.get("/api/notifications").json()["items"]
|
|
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
|
|
assert accepted, inbox
|
|
assert "Features" in accepted[0]["summary"]
|
|
|
|
|
|
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
|
|
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
|
|
the grant; the membership row carries the granted (not requested) role."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
|
|
req_id = _join_requests("collection", "features")[0]["id"]
|
|
|
|
_login(client, 5, "eve") # project Owner — reach covers the collection
|
|
r = client.post(
|
|
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
|
json={"role": "contributor"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert ("collection", "features", "contributor") in _membership(2)
|
|
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
|
|
|
|
|
|
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
req_id = _join_requests("collection", "features")[0]["id"]
|
|
|
|
_login(client, 4, "dan")
|
|
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
|
|
assert r.status_code == 200, r.text
|
|
assert _membership(2) == set()
|
|
assert _join_requests("collection", "features")[0]["status"] == "declined"
|
|
|
|
_login(client, 2, "ben")
|
|
inbox = client.get("/api/notifications").json()["items"]
|
|
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gates & guards
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
assert r1.status_code == 200, r1.text
|
|
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
assert r2.status_code == 409, r2.text
|
|
|
|
|
|
def test_existing_member_cannot_request(app_with_fake_gitea):
|
|
"""dan already owns the collection — there is nothing to request (409)."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 4, "dan")
|
|
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
assert r.status_code == 409, r.text
|
|
|
|
|
|
def test_non_owner_cannot_accept(app_with_fake_gitea):
|
|
"""A plain requester (or any non-Owner) is refused the accept action."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
req_id = _join_requests("collection", "features")[0]["id"]
|
|
# provision a second plain account that tries to accept
|
|
provision_user_row(user_id=12, login="mal", role="contributor")
|
|
_login(client, 12, "mal")
|
|
r = client.post(
|
|
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
|
|
)
|
|
assert r.status_code == 403, r.text
|
|
assert _membership(2) == set()
|
|
|
|
|
|
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
|
|
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
|
|
req_id = _join_requests("collection", "model")[0]["id"]
|
|
_login(client, 4, "dan")
|
|
r = client.post(
|
|
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
|
|
)
|
|
assert r.status_code == 403, r.text
|
|
|
|
|
|
def test_unknown_scope_404(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
_login(client, 2, "ben")
|
|
assert client.post(
|
|
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
|
|
).status_code == 404
|
|
assert client.post(
|
|
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
|
|
).status_code == 404
|
|
# 'global' is not a join-able scope_type.
|
|
assert client.post(
|
|
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
|
|
).status_code == 404
|
|
|
|
|
|
def test_join_target_reports_eligibility(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
# ben: eligible (granted, no role).
|
|
_login(client, 2, "ben")
|
|
t = client.get("/api/scopes/collection/features/join-target").json()
|
|
assert t["eligible"] is True
|
|
assert t["name"] == "Features"
|
|
assert t["current_role"] is None
|
|
# after requesting, already_requested flips and eligible drops.
|
|
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
|
t2 = client.get("/api/scopes/collection/features/join-target").json()
|
|
assert t2["already_requested"] is True
|
|
assert t2["eligible"] is False
|
|
# dan: already a member → ineligible with current_role.
|
|
_login(client, 4, "dan")
|
|
t3 = client.get("/api/scopes/collection/features/join-target").json()
|
|
assert t3["eligible"] is False
|
|
assert t3["current_role"] == "owner"
|