Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
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
175 lines
8.3 KiB
Python
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")
|