coulomb-social/tests/test_member_portability.py
tegwick 021f0a5916
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Prove the global space-slug constraint for WP-0013-T01
Migration 0003 refuses duplicate content slugs and rolls back by dropping
only spaces_space_slug_global_uniq. Tests cover the SQL boundary, import
and admin races, and legacy fail-closed reads. The live database is not
migrated.

Assistant: grok
Assistant-Session: 01a12cc2-e3b2-77a3-8aef-9cc60e577fee
2026-10-11 23:12:18 +02:00

175 lines
8.3 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")]
space = create_space(owner, title="Retained work", slug="retained-work")
client.force_login(owner.user)
return owner, reader, outsider, space
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", ["foreign", "missing", "malformed"])
def test_sharing_rejects_foreign_missing_and_malformed_ids(people, client, target):
_, _, _, space = people
ids = {"foreign": str(make_member("foreign", tenant="tenant:b").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"existing member in this tenant" 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=owner, 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_rejects_cross_tenant_grants_and_colliding_storage(people):
_, _, _, space = people
foreign = make_member("foreign", tenant="tenant:b")
with pytest.raises(ValidationError, match="another tenant"):
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_corrupt_foreign_creator_is_blocked_without_disclosing_other_tenant(people, client):
owner, _, _, _ = people
Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign private title",
created_by=owner)
response = client.get(reverse("core:account_data"))
assert b"operator review" in response.content
assert b"Foreign private title" not in response.content
response = client.post(reverse("core:account_data"), {"confirmation": "DELETE"})
assert b"Foreign private title" not in response.content
assert Member.objects.filter(pk=owner.pk).exists()
@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")