coulomb-social/tests/test_space_permissions.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

138 lines
5.8 KiB
Python

"""User journey UC-005: reader visibility with mutation denial."""
import pytest
from django.db import IntegrityError
from django.urls import reverse
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.services import (
can_write_space,
copy_space_page,
create_space,
put_space_page,
transfer_space_page,
)
def make_member(subject, tenant="tenant:a"):
return Member.objects.create(
tenant_id=tenant, user=User.objects.create_user(username=subject),
user_engine_user_id=subject, issuer="https://issuer.test", subject=subject,
)
@pytest.fixture
def shared(tmp_path, settings, client):
settings.CONTENT_ROOT = str(tmp_path / "content")
owner = make_member("owner")
reader = make_member("reader")
outsider = make_member("outsider")
space = create_space(owner, title="Shared", slug="shared")
put_space_page(owner, space, "notes", title="Notes", body="Original work")
SpaceMembership.objects.create(space=space, member=reader, role="viewer")
client.force_login(reader.user)
return owner, reader, outsider, space
@pytest.mark.django_db
@pytest.mark.parametrize("route", ["edit", "archive", "page_new", "page_edit",
"page_delete", "page_copy", "page_transfer"])
def test_reader_cannot_mutate_by_direct_request(shared, client, route):
_, _, _, space = shared
kwargs = {"slug": space.slug}
if route in {"page_edit", "page_delete", "page_copy", "page_transfer"}:
kwargs["page_slug"] = "notes"
response = client.post(reverse("spaces:" + route, kwargs=kwargs),
{"title": "Changed", "body": "Tampered",
"dest_space": "shared", "dest_slug": "copy"})
assert response.status_code == 403
assert pageops.read_page("shared", "notes").body == "Original work\n"
assert not pageops.read_page("shared", "copy").exists
space.refresh_from_db()
assert space.title == "Shared"
assert space.is_active
@pytest.mark.django_db
def test_reader_sees_read_only_controls_outsider_sees_no_content(shared, client):
_, _, outsider, space = shared
url = reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=notes"
response = client.get(url)
assert response.status_code == 200
assert b"Original work" in response.content
assert b"Read-only access" in response.content
assert b"Edit page" not in response.content
assert b"New page" not in response.content
client.force_login(outsider.user)
response = client.get(url)
assert response.status_code == 404
assert b"Original work" not in response.content
@pytest.mark.django_db
def test_member_can_edit_but_viewer_cannot_use_services(shared):
_, reader, _, space = shared
with pytest.raises(pageops.PageOpsError, match="read-only"):
put_space_page(reader, space, "notes", title="Changed")
membership = SpaceMembership.objects.get(space=space, member=reader)
membership.role = "member"
membership.save()
put_space_page(reader, space, "notes", title="Changed", body="Allowed")
assert pageops.read_page("shared", "notes").body == "Allowed\n"
@pytest.mark.django_db
@pytest.mark.parametrize("operation", ["copy", "move"])
def test_destination_read_only_denies_transfer_without_source_change(shared, operation):
owner, _, _, source = shared
other = make_member("destination-owner")
destination = create_space(other, title="Destination", slug="destination")
SpaceMembership.objects.create(space=destination, member=owner, role="viewer")
with pytest.raises(pageops.PageOpsError, match="read-only"):
if operation == "copy":
copy_space_page(owner, source, "notes", dest_space=destination, dest_slug="copy")
else:
transfer_space_page(owner, source, "notes", destination)
assert pageops.read_page("shared", "notes").exists
assert not pageops.read_page("destination", "notes").exists
assert not pageops.read_page("destination", "copy").exists
@pytest.mark.django_db(transaction=True)
def test_same_slug_other_tenant_cannot_be_written(shared):
from tests.legacy_slug import without_global_slug_constraint
owner, _, _, _ = shared
with pytest.raises(IntegrityError):
Space.objects.create(tenant_id="tenant:b", slug="shared", title="Foreign",
created_by=owner)
assert pageops.read_page("shared", "notes").body == "Original work\n"
with without_global_slug_constraint():
foreign = Space.objects.create(tenant_id="tenant:b", slug="shared", title="Foreign",
created_by=owner)
assert not can_write_space(owner, foreign)
with pytest.raises(pageops.PageOpsError, match="read-only"):
put_space_page(owner, foreign, "notes", title="Changed")
assert pageops.read_page("shared", "notes").body == "Original work\n"
@pytest.mark.django_db
def test_space_without_grants_is_not_tenant_public(client):
member = make_member("ungranted")
space = Space.objects.create(tenant_id=member.tenant_id, slug="private", title="Private")
client.force_login(member.user)
assert client.get(reverse("spaces:detail", kwargs={"slug": space.slug})).status_code == 404
assert b"Private" not in client.get(reverse("core:app_home")).content
@pytest.mark.django_db
def test_explicit_reader_role_overrides_creator_write_grant(shared):
owner, _, _, space = shared
membership = SpaceMembership.objects.get(space=space, member=owner)
membership.role = "viewer"
membership.save()
assert not can_write_space(owner, space)
with pytest.raises(pageops.PageOpsError, match="read-only"):
put_space_page(owner, space, "notes", title="Should not change")