eb0350733b
TreeMembership was enforced on every read/write but had no API or UI to manage
members — trees were effectively single-user, breaking full-CRUD (NN#8).
Backend (/trees/{id}/members): list (members only — the list exposes emails, so
non-members never see it, even on public trees); add an existing user by email
(owner only, 404 if no such account, 409 if already a member); PATCH role;
DELETE. A tree must always keep ≥1 owner (demote/remove of the sole owner → 409).
All changes audited.
Frontend: a Members page (owner gets add-by-email + per-member role select +
remove; others see a read-only list) and a sidebar entry.
Test covers the full lifecycle + every guard. Suite 77 passed.
Closes #145
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
75 lines
2.8 KiB
Python
75 lines
2.8 KiB
Python
"""Tree membership management: list, add-by-email, role change, remove, guards."""
|
|
|
|
from tests.conftest import auth, register
|
|
|
|
|
|
async def test_membership_management(client):
|
|
owner = auth(await register(client, "mm-owner@ex.com"))
|
|
ed = auth(await register(client, "mm-editor@ex.com"))
|
|
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=owner)).json()["id"]
|
|
|
|
# A non-member can't even see the member list of a private tree.
|
|
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403
|
|
|
|
# Add a non-existent user → 404.
|
|
assert (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/members",
|
|
json={"email": "ghost@ex.com", "role": "editor"},
|
|
headers=owner,
|
|
)
|
|
).status_code == 404
|
|
|
|
# Add the editor by email.
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/members",
|
|
json={"email": "mm-editor@ex.com", "role": "editor"},
|
|
headers=owner,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
mid = r.json()["id"]
|
|
assert r.json()["email"] == "mm-editor@ex.com" and r.json()["role"] == "editor"
|
|
|
|
# Adding the same user again → 409.
|
|
assert (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/members",
|
|
json={"email": "mm-editor@ex.com", "role": "viewer"},
|
|
headers=owner,
|
|
)
|
|
).status_code == 409
|
|
|
|
# The editor can now see the tree's member list (2 members)...
|
|
ml = (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).json()
|
|
assert len(ml) == 2
|
|
owner_mid = next(m["id"] for m in ml if m["role"] == "owner")
|
|
# ...but a non-owner can't manage members.
|
|
assert (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/members",
|
|
json={"email": "mm-owner@ex.com", "role": "viewer"},
|
|
headers=ed,
|
|
)
|
|
).status_code == 403
|
|
|
|
# Owner changes the editor's role.
|
|
pr = await client.patch(
|
|
f"/api/v1/trees/{tid}/members/{mid}", json={"role": "viewer"}, headers=owner
|
|
)
|
|
assert pr.status_code == 200 and pr.json()["role"] == "viewer"
|
|
|
|
# The sole owner can't be demoted or removed.
|
|
assert (
|
|
await client.patch(
|
|
f"/api/v1/trees/{tid}/members/{owner_mid}", json={"role": "editor"}, headers=owner
|
|
)
|
|
).status_code == 409
|
|
assert (
|
|
await client.delete(f"/api/v1/trees/{tid}/members/{owner_mid}", headers=owner)
|
|
).status_code == 409
|
|
|
|
# Owner removes the editor; the list shrinks and the editor loses access.
|
|
assert (await client.delete(f"/api/v1/trees/{tid}/members/{mid}", headers=owner)).status_code == 204
|
|
assert len((await client.get(f"/api/v1/trees/{tid}/members", headers=owner)).json()) == 1
|
|
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403
|