intent/tests/test_files.py

267 lines
11 KiB
Python
Raw Permalink Normal View History

import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from intent.comments import services as comment_services
from intent.comments.models import CommentThread, ThreadKind
from intent.features.services import create_feature
from intent.files import services
from intent.files.models import FeatureFileVersion, FileKind
from intent.issues.services import promotion_text
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def upload(name="mockup.png", data=PNG):
return SimpleUploadedFile(name, data, content_type="application/octet-stream")
def furl(route, file=None, feature=None, **kwargs):
feature = feature or file.feature
kwargs = {
"org_slug": "catbriar",
"project_slug": "intent",
"feature_key": feature.key,
**kwargs,
}
if file is not None:
kwargs["file_id"] = file.pk
return reverse(f"files:{route}", kwargs=kwargs)
def raw_url(version):
return furl("raw", version.file, version=version.version, name=version.original_name)
@pytest.fixture
def feature(open_project, owner):
return create_feature(open_project, owner, title="Sign in")
@pytest.fixture
def image(feature, owner):
return services.add_file(
feature, owner, kind=FileKind.PROTOTYPE, upload=upload(), note="First mockup"
)
def current(file):
return file.versions.get(version=file.current_version)
def test_adding_a_file_is_a_revision(feature, image):
feature.refresh_from_db()
revision = feature.revisions.get(revision=2)
assert feature.current_revision == 2 and image.added_in_revision == 2
assert revision.files_change == "Added prototype “mockup.png”"
assert revision.note == "First mockup"
assert current(image).content_type == "image/png"
def test_type_is_decided_by_extension_and_checked(feature, owner):
with pytest.raises(ValidationError, match="isn't allowed"):
services.add_file(feature, owner, kind="prototype", upload=upload("x.exe"), note="n")
with pytest.raises(ValidationError, match="empty"):
services.add_file(feature, owner, kind="prototype", upload=upload(data=b""), note="n")
def test_size_cap(feature, owner, monkeypatch):
monkeypatch.setattr(services, "MAX_BYTES", 10)
with pytest.raises(ValidationError, match="up to"):
services.add_file(feature, owner, kind="prototype", upload=upload(), note="n")
def test_note_required_and_editors_only(feature, owner, collaborator):
with pytest.raises(ValidationError):
services.add_file(feature, owner, kind="prototype", upload=upload(), note=" ")
with pytest.raises(PermissionDenied):
services.add_file(feature, collaborator, kind="prototype", upload=upload(), note="n")
def test_replace_keeps_old_versions_and_their_threads(image, owner, outsider):
old = current(image)
pin = comment_services.start_file_thread(old, outsider, body="Here", x=0.25, y=0.5)
services.replace_file(image, owner, upload=upload("mockup-2.png"), note="Tweak")
image.refresh_from_db()
assert (image.current_version, image.name) == (2, "mockup-2.png")
assert image.feature.revisions.last().files_change == "Replaced “mockup.png” with version 2"
assert CommentThread.objects.get(pk=pin.pk).file_version == old
with pytest.raises(ValidationError, match="latest"):
comment_services.start_file_thread(old, outsider, body="Late")
def test_remove_and_files_at_revision(feature, image, owner):
services.replace_file(image, owner, upload=upload("v2.png"), note="v2") # revision 3
services.remove_file(image, owner, note="Not needed") # revision 4
image.refresh_from_db()
assert image.removed_in_revision == 4
assert [v.version for v in services.files_at(feature, 1)] == []
assert [v.version for v in services.files_at(feature, 2)] == [1]
assert [v.version for v in services.files_at(feature, 3)] == [2]
assert services.files_at(feature, 4) == []
with pytest.raises(ValidationError):
services.remove_file(image, owner, note="Again")
def test_image_pins_need_an_image_and_a_point(feature, image, owner, outsider):
doc = services.add_file(
feature, owner, kind="documentation", upload=upload("notes.md", b"# Notes"), note="n"
)
with pytest.raises(ValidationError, match="Only images"):
comment_services.start_file_thread(current(doc), outsider, body="x", x=0.1, y=0.1)
with pytest.raises(ValidationError, match="point"):
comment_services.start_file_thread(current(image), outsider, body="x", x=0.1)
with pytest.raises(ValidationError, match="point"):
comment_services.start_file_thread(current(image), outsider, body="x", x=1.5, y=0.1)
file_thread = comment_services.start_file_thread(current(doc), outsider, body="Whole")
assert file_thread.kind == ThreadKind.FILE
def test_promotion_text_mentions_the_pin(image, outsider):
thread = comment_services.start_file_thread(
current(image), outsider, body="Button too small", x=0.25, y=0.5
)
_, body = promotion_text(thread)
assert "file “mockup.png” version 1, at 25% across and 50% down" in body
# Views
def test_upload_via_feature_page(client, feature, editor):
client.force_login(editor)
response = client.post(
furl("add", feature=feature),
{"kind": "prototype", "upload": upload(), "note": "Mockup"},
)
file = feature.files.get()
assert response["Location"] == furl("detail", file)
page = client.get(
reverse(
"features:detail",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
).content.decode()
assert furl("detail", file) in page
def test_bad_upload_via_page_shows_error(client, feature, editor):
client.force_login(editor)
response = client.post(
furl("add", feature=feature),
{"kind": "prototype", "upload": upload("evil.exe"), "note": "x"},
follow=True,
)
assert b"isn't allowed" in response.content
assert not feature.files.exists()
def test_pin_via_form_and_page_shows_it(client, image, outsider):
client.force_login(outsider)
response = client.post(
furl("thread_create", image), {"body": "Too small", "across": "25", "down": "50"}
)
thread = CommentThread.objects.get()
assert (thread.kind, thread.x, thread.y) == (ThreadKind.IMAGE, 0.25, 0.5)
assert response["Location"] == furl("detail", image) + f"#thread-{thread.pk}"
page = client.get(furl("detail", image)).content.decode()
assert '<circle cx="25.0%" cy="50.0%"' in page
assert "Pin 1: Thread by uma" in page
def test_htmx_reply_on_a_pin_keeps_its_number(client, image, outsider):
thread = comment_services.start_file_thread(current(image), outsider, body="Here", x=0.1, y=0.2)
client.force_login(outsider)
response = client.post(
reverse(
"comments:reply",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "thread_id": thread.pk},
),
{"body": "More"},
headers={"HX-Request": "true"},
)
assert b"Pin 1: Thread by uma" in response.content
def test_raw_file_permission_and_headers(client, project, owner, make_user):
feature = create_feature(project, owner, title="Private")
html = services.add_file(
feature, owner, kind="prototype", upload=upload("proto.html", b"<p>hi</p>"), note="n"
)
url = raw_url(current(html))
assert client.get(url).status_code == 302 # Signed out: sign in first.
client.force_login(make_user())
assert client.get(url).status_code == 404 # Outsider: private project is invisible.
client.force_login(owner)
response = client.get(url)
assert response["Content-Security-Policy"].startswith("sandbox allow-scripts")
assert "allow-same-origin" not in response["Content-Security-Policy"]
assert response["X-Content-Type-Options"] == "nosniff"
assert response["X-Frame-Options"] == "SAMEORIGIN"
assert response["Content-Type"] == "text/html"
assert b"".join(response.streaming_content) == b"<p>hi</p>"
def test_svg_and_zip_cannot_run_or_render(client, feature, owner):
svg = services.add_file(
feature, owner, kind="prototype", upload=upload("a.svg", b"<svg/>"), note="n"
)
zipped = services.add_file(
feature, owner, kind="documentation", upload=upload("a.zip", b"PK"), note="n"
)
client.force_login(owner)
svg_response = client.get(raw_url(current(svg)))
assert svg_response["Content-Security-Policy"].startswith("sandbox;")
zip_response = client.get(raw_url(current(zipped)))
assert zip_response["Content-Disposition"].startswith("attachment")
def test_bucket_storage_hands_off_to_a_signed_url(client, image, owner, monkeypatch):
version = current(image)
storage = version.blob.storage
calls = {}
def fake_url(name, parameters=None, **kwargs):
calls.update(parameters)
return f"https://bucket.example/{name}?signature=x"
monkeypatch.setattr(storage, "querystring_auth", True, raising=False)
monkeypatch.setattr(storage, "url", fake_url)
client.force_login(owner)
response = client.get(raw_url(version))
assert response.status_code == 302
assert response["Location"].startswith("https://bucket.example/files/")
assert calls["ResponseContentType"] == "image/png"
assert calls["ResponseContentDisposition"].startswith("inline")
def test_markdown_file_renders_on_its_page(client, feature, owner):
doc = services.add_file(
feature, owner, kind="documentation", upload=upload("notes.md", b"# Notes\n\nHi"),
note="n",
) # fmt: skip
client.force_login(owner)
page = client.get(furl("detail", doc)).content.decode()
assert "<h2" in page and "Notes" in page
def test_old_version_page_and_unknown_version(client, image, owner):
services.replace_file(image, owner, upload=upload("v2.png"), note="v2")
client.force_login(owner)
page = client.get(furl("detail", image) + "?version=1").content.decode()
assert "Older version" in page and 'id="file-comment"' not in page
assert client.get(furl("detail", image) + "?version=9").status_code == 404
def test_file_from_another_feature_is_404(client, image, open_project, owner):
other = create_feature(open_project, owner, title="Other")
client.force_login(owner)
assert client.get(furl("detail", image, feature=other)).status_code == 404
def test_versions_are_stored_under_opaque_paths(image):
name = FeatureFileVersion.objects.get().blob.name
assert name.startswith("files/") and name.endswith("/mockup.png")
assert image.feature.key not in name