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