coulomb-social/tests/test_member_portability.py
tegwick 1c69bdce62
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Allow explicit space grants across platform identity tenants
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
2026-11-01 20:48:33 +01:00

194 lines
9 KiB
Python

"""App-local sharing/exit with synthetic identities and retained shared work."""
import pytest
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.members.portability import delete_local_member
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.services import create_space, set_space_access
from tests.test_space_permissions import make_member
pytestmark = pytest.mark.django_db
@pytest.fixture
def people(tmp_path, settings, client):
settings.CONTENT_ROOT = str(tmp_path / "content")
owner, reader, outsider = [make_member(s) for s in ("owner", "reader", "outsider")]
reader.tenant_id = "tenant:b"
reader.save()
space = create_space(owner, title="Retained work", slug="retained-work")
client.force_login(owner.user)
return owner, reader, outsider, space
@pytest.mark.usecase("CSOC-UC-005")
def test_owner_shares_reader_then_revokes_without_email(people, client):
owner, reader, _, space = people
url = reverse("spaces:sharing", kwargs={"slug": space.slug})
assert b"Share space" in client.get(reverse("spaces:detail", args=[space.slug])).content
assert client.post(url, {"member_id": str(reader.pk), "role": "viewer"}).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200
assert client.get(url).status_code == 403
assert client.post(url, {"member_id": str(owner.pk), "role": "remove"}).status_code == 403
client.force_login(owner.user)
assert client.post(url, {"member_id": str(reader.pk), "role": "remove"}).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404
@pytest.mark.parametrize("target", ["inactive", "missing", "malformed"])
def test_sharing_rejects_inactive_missing_and_malformed_ids(people, client, target):
_, _, _, space = people
inactive = make_member("inactive")
inactive.user.is_active = False
inactive.user.save()
ids = {"inactive": str(inactive.pk),
"missing": "00000000-0000-0000-0000-000000000000", "malformed": "bad-id"}
url = reverse("spaces:sharing", args=[space.slug])
response = client.post(url, {"member_id": ids[target], "role": "owner"})
assert response.status_code == 200
assert b"active Coulomb member" in response.content
assert space.memberships.count() == 1
def test_creator_and_last_owner_cannot_be_revoked(people):
owner, reader, _, space = people
with pytest.raises(pageops.PageOpsError, match="creator"):
set_space_access(owner, space, owner.pk, "remove")
set_space_access(owner, space, reader.pk, "owner")
space.created_by = None
space.save()
set_space_access(reader, space, owner.pk, "remove")
with pytest.raises(pageops.PageOpsError, match="at least one owner"):
set_space_access(reader, space, reader.pk, "viewer")
def test_export_is_personal_tenant_scoped_and_never_exports_secrets(people, client):
owner, reader, _, space = people
reader.email = "other-person@example.test"
reader.save()
set_space_access(owner, space, reader.pk, "viewer")
foreign = Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign")
SpaceMembership.objects.create(space=foreign, member=reader, role="viewer")
session = client.session
session["access_token"] = "synthetic-secret"
session.save()
response = client.get(reverse("core:account_export"))
assert response.status_code == 200
assert response["Cache-Control"] == "private, no-store"
assert response["Content-Disposition"].startswith("attachment;")
data = response.json()
assert data["member"]["id"] == str(owner.pk)
assert [g["space_slug"] for g in data["memberships"]] == [space.slug]
for private in (b"synthetic-secret", b"other-person@example.test", b"password", b"Foreign"):
assert private not in response.content
client.force_login(reader.user)
assert client.get(reverse("core:account_export")).json()["member"]["id"] == str(reader.pk)
@pytest.mark.parametrize("archived", [False, True])
def test_exit_blocks_last_owner_and_retains_content(people, client, archived):
owner, _, _, space = people
space.is_active = not archived
space.save()
before = pageops.page_file(space.slug, "index").read_bytes()
url = reverse("core:account_data")
assert b"Before removal" in client.get(url).content
assert client.post(url, {"confirmation": "DELETE"}).status_code == 200
assert Member.objects.filter(pk=owner.pk).exists()
assert pageops.page_file(space.slug, "index").read_bytes() == before
def test_exit_with_successor_removes_local_grants_and_all_old_sessions(people, client):
owner, reader, _, space = people
set_space_access(owner, space, reader.pk, "owner")
other_session = Client()
other_session.force_login(owner.user)
before = pageops.page_file(space.slug, "index").read_bytes()
url = reverse("core:account_data")
assert client.post(url, {"confirmation": "wrong"}).status_code == 200
response = client.post(url, {"confirmation": "DELETE"})
assert response.status_code == 302
assert not Member.objects.filter(pk=owner.pk).exists()
assert not User.objects.filter(pk=owner.user_id).exists()
space.refresh_from_db()
assert space.created_by_id is None
assert pageops.page_file(space.slug, "index").read_bytes() == before
assert other_session.get(reverse("core:app_home")).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200
def test_inactive_successor_cannot_release_owner_responsibility(people):
owner, reader, _, space = people
set_space_access(owner, space, reader.pk, "owner")
reader.user.is_active = False
reader.user.save()
with pytest.raises(pageops.PageOpsError, match="another active member"):
delete_local_member(owner)
def test_account_and_sharing_posts_require_csrf(people):
owner, reader, _, space = people
guarded = Client(enforce_csrf_checks=True)
guarded.force_login(owner.user)
assert guarded.post(reverse("core:account_data"), {"confirmation": "DELETE"}).status_code == 403
assert guarded.post(reverse("spaces:sharing", args=[space.slug]), {
"member_id": str(reader.pk), "role": "owner",
}).status_code == 403
def test_admin_validation_allows_explicit_cross_tenant_grants_but_rejects_colliding_storage(people):
_, _, _, space = people
foreign = make_member("foreign", tenant="tenant:b")
SpaceMembership(space=space, member=foreign).full_clean()
with pytest.raises(ValidationError, match="already uses that slug"):
Space(tenant_id=foreign.tenant_id, slug=space.slug, title="Collision").full_clean()
def test_identity_tenant_change_does_not_erase_ownership_or_allow_abandonment(people, client):
owner, reader, _, space = people
owner.tenant_id = "tenant:changed"
owner.save()
response = client.get(reverse("core:account_data"))
assert space.title.encode() in response.content
assert b"give another active member owner access" in response.content
response = client.post(reverse("core:account_data"), {"confirmation": "DELETE"})
assert Member.objects.filter(pk=owner.pk).exists()
set_space_access(owner, space, reader.pk, "owner")
assert client.post(reverse("core:account_data"), {"confirmation": "DELETE"}).status_code == 302
assert not Member.objects.filter(pk=owner.pk).exists()
client.force_login(reader.user)
assert client.get(reverse("spaces:sharing", args=[space.slug])).status_code == 200
def test_personal_export_includes_only_own_cross_tenant_grants(people, client):
owner, reader, _, space = people
set_space_access(owner, space, reader.pk, "viewer")
client.force_login(reader.user)
data = client.get(reverse("core:account_export")).json()
assert [g["space_id"] for g in data["memberships"]] == [str(space.pk)]
assert data["member"]["id"] == str(reader.pk)
assert str(owner.pk) not in str(data)
@pytest.mark.django_db(transaction=True)
def test_legacy_ambiguous_storage_denies_pages_lists_and_writes(people, client):
from tests.legacy_slug import without_global_slug_constraint
owner, _, _, space = people
with without_global_slug_constraint():
Space.objects.create(tenant_id="tenant:b", slug=space.slug, title="Collision")
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404
edit = client.get(reverse("spaces:page_edit", args=[space.slug, "index"]))
assert edit.status_code == 404
assert space.title.encode() not in client.get(reverse("core:app_home")).content
with pytest.raises(pageops.PageOpsError, match="read-only"):
from coulomb_social.apps.spaces.services import put_space_page
put_space_page(owner, space, "notes", title="Attempt")