"""Author-managed attachments preserve permissions, revisions and crash recovery.""" import os import subprocess import sys import pytest from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client from django.urls import reverse from coulomb_social.apps.spaces import attachments, pageops from coulomb_social.apps.spaces.assets import local_download_url from coulomb_social.apps.spaces.content import load_space_page from coulomb_social.apps.spaces.models import SpaceMembership from coulomb_social.apps.spaces.services import create_space, put_space_page 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(name) for name in ("author", "reader", "outsider")] reader.tenant_id = "tenant:b" reader.save() space = create_space(owner, title="Shared files", slug="shared-files") SpaceMembership.objects.create(space=space, member=reader, role="viewer") doc = put_space_page(owner, space, "notes", title="Notes", body="Retained notes", extra_meta={"source_created_at": "2020-01-02T03:04:05Z"}) client.force_login(owner.user) return space, doc, owner, reader, outsider def upload(space, doc, name="Report.pdf", data=b"synthetic PDF"): return attachments.add(space.slug, doc.slug, SimpleUploadedFile(name, data), revision=doc.revision) def test_upload_download_and_remove_preserve_source_and_retained_revisions(people, client): space, doc, _, reader, _ = people url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) result = client.post(url, {"action": "add", "revision": doc.revision, "file": SimpleUploadedFile("Report.pdf", b"synthetic PDF")}) assert result.status_code == 302 added = pageops.read_page(space.slug, doc.slug) entry, = attachments.entries(added.meta) assert entry["name"] == "Report.pdf" assert added.body == doc.body assert added.meta["source_created_at"] == doc.meta["source_created_at"] client.force_login(reader.user) response = client.get(local_download_url(space.slug, entry["path"])) assert response.status_code == 200 assert response["Content-Type"] == "application/octet-stream" assert response["Content-Disposition"].startswith("attachment;") assert response["Cache-Control"] == "private, no-store" assert response["X-Content-Type-Options"] == "nosniff" assert b"".join(response.streaming_content) == b"synthetic PDF" client.force_login(people[2].user) assert client.post(url, {"action": "remove", "revision": added.revision, "reference": entry["path"]}).status_code == 302 current = pageops.read_page(space.slug, doc.slug) assert attachments.entries(current.meta) == [] and current.body == doc.body assert (pageops.space_dir(space.slug) / entry["path"]).read_bytes() == b"synthetic PDF" retained = pageops.content_root() / ".revisions" / space.slug / doc.slug assert (retained / (doc.revision + ".md")).is_file() assert entry["path"] in (retained / (added.revision + ".md")).read_text() def test_attachment_permissions_and_revocation_cover_reads_and_mutations(people, client): space, doc, owner, reader, outsider = people added = upload(space, doc) entry, = attachments.entries(added.meta) url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) download = local_download_url(space.slug, entry["path"]) foreign = make_member("foreign", tenant="tenant:b") client.force_login(reader.user) assert client.get(url).status_code == 200 assert b"Add attachment" not in client.get(url).content assert client.post(url, {"action": "remove"}).status_code == 403 for member in (outsider, foreign): client.force_login(member.user) assert client.get(url).status_code == client.get(download).status_code == 404 assert client.post(url, {"action": "add"}).status_code == 404 SpaceMembership.objects.filter(space=space, member=reader).delete() client.force_login(reader.user) assert client.get(url).status_code == client.get(download).status_code == 404 client.force_login(owner.user) assert client.post(download).status_code == 405 space.is_active = False space.save() assert client.get(download).status_code == 404 client.logout() assert client.get(url).status_code == client.get(download).status_code == 302 def test_stale_missing_or_foreign_attachment_actions_do_not_change_files(people, client): space, doc, owner, _, _ = people added = upload(space, doc) before = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()} for revision in (doc.revision, None, ""): with pytest.raises(pageops.PageOpsError, match="changed"): attachments.add(space.slug, doc.slug, SimpleUploadedFile("more.txt", b"more"), revision=revision) with pytest.raises(pageops.PageOpsError, match="not found"): attachments.remove(space.slug, doc.slug, "assets/elsewhere.txt", revision=added.revision) after = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()} assert after == before guarded = Client(enforce_csrf_checks=True) guarded.force_login(owner.user) url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) assert guarded.post(url, {"action": "remove"}).status_code == 403 @pytest.mark.parametrize("path", ["../pages/notes.md", "nested/../../private", "..%2fsecret", "missing", "link.txt", "folder/link.txt"]) def test_download_refuses_missing_traversal_and_symlinks(people, client, tmp_path, path): space, _, _, _, _ = people secret = tmp_path / "secret.txt" secret.write_text("private") (pageops.assets_dir(space.slug) / "link.txt").symlink_to(secret) (pageops.assets_dir(space.slug) / "folder").symlink_to(tmp_path, target_is_directory=True) assert client.get(reverse("spaces:download", args=[space.slug, path])).status_code == 404 def test_active_content_is_download_only_and_filenames_are_escaped(people, client): space, doc, _, _, _ = people added = upload(space, doc, '.html', b'') entry, = attachments.entries(added.meta) response = client.get(local_download_url(space.slug, entry["path"])) assert response["Content-Disposition"].startswith("attachment;") assert response["Content-Type"] == "application/octet-stream" assert "sandbox" in response["Content-Security-Policy"] assert b"".join(response.streaming_content) == b'' menu = client.get(reverse("spaces:page_attachments", args=[space.slug, doc.slug])) assert b"