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 'hi

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

hi

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