"""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")