Files: prototype and documentation files on features, with versions. Adding, replacing or removing a file is a feature revision with a change note. Types come from the extension; uploads are capped at 25 MB and stored under opaque paths. Every file URL checks project access, then redirects to a short-lived signed URL on the bucket, or streams with nosniff and a CSP sandbox when no bucket is configured. HTML prototypes run in a sandboxed iframe without same-origin access. Threads: whole-file threads and pins at a point on an image, drawn as an SVG overlay in percentages. Pins are placed with number fields, or by clicking the image with JS. Implementations: name, link and status, plus a per-feature status grid stamped with the feature revision, marked behind when the feature changes, and confirmable without changing the status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
11 KiB
Python
266 lines
11 KiB
Python
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
|