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
138 lines
5.8 KiB
Python
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")
|