ML-181: harden assets endpoints

This commit is contained in:
Claudio Ortolina
2026-05-22 08:20:35 +01:00
parent 5601d681e9
commit d8a67cd475
6 changed files with 202 additions and 31 deletions
@@ -2,4 +2,52 @@ defmodule MusicLibrary.Assets.TransformTest do
use ExUnit.Case, async: true
doctest MusicLibrary.Assets.Transform
alias MusicLibrary.Assets.Transform
defp make_payload(params) do
params |> JSON.encode!() |> Base.url_encode64(padding: false)
end
describe "decode/1 width validation" do
test "rejects string width" do
payload = make_payload(%{hash: "abc", width: "300"})
assert {:error, :invalid_payload} = Transform.decode(payload)
end
test "rejects negative width" do
payload = make_payload(%{hash: "abc", width: -1})
assert {:error, :invalid_payload} = Transform.decode(payload)
end
test "rejects zero width" do
payload = make_payload(%{hash: "abc", width: 0})
assert {:error, :invalid_payload} = Transform.decode(payload)
end
test "rejects float width" do
payload = make_payload(%{hash: "abc", width: 300.5})
assert {:error, :invalid_payload} = Transform.decode(payload)
end
test "rejects very large width" do
payload = make_payload(%{hash: "abc", width: 99_999})
assert {:error, :invalid_payload} = Transform.decode(payload)
end
test "accepts nil width" do
payload = make_payload(%{hash: "abc", width: nil})
assert {:ok, %Transform{hash: "abc", width: nil}} = Transform.decode(payload)
end
test "accepts max allowed width 2048" do
payload = make_payload(%{hash: "abc", width: 2048})
assert {:ok, %Transform{hash: "abc", width: 2048}} = Transform.decode(payload)
end
test "accepts width 1" do
payload = make_payload(%{hash: "abc", width: 1})
assert {:ok, %Transform{hash: "abc", width: 1}} = Transform.decode(payload)
end
end
end
@@ -87,6 +87,67 @@ defmodule MusicLibraryWeb.AssetControllerTest do
assert text_response(conn, 404) == "Not found"
end
test "400s when payload has string width", %{conn: conn, asset: asset} do
payload =
%{hash: asset.hash, width: "300"}
|> JSON.encode!()
|> Base.url_encode64(padding: false)
conn = get(conn, ~p"/assets/#{payload}")
assert text_response(conn, 400) == "Bad request"
end
test "400s when payload has negative width", %{conn: conn, asset: asset} do
payload =
%{hash: asset.hash, width: -1}
|> JSON.encode!()
|> Base.url_encode64(padding: false)
conn = get(conn, ~p"/assets/#{payload}")
assert text_response(conn, 400) == "Bad request"
end
test "400s when payload has very large width", %{conn: conn, asset: asset} do
payload =
%{hash: asset.hash, width: 99_999}
|> JSON.encode!()
|> Base.url_encode64(padding: false)
conn = get(conn, ~p"/assets/#{payload}")
assert text_response(conn, 400) == "Bad request"
end
test "canonical cache key collapses variant payloads into single ETS entry", %{
conn: conn,
asset: asset
} do
# Two variant payloads encoding the same (hash, width)
payload_a =
%{hash: asset.hash, width: 480}
|> JSON.encode!()
|> Base.url_encode64(padding: false)
# Same hash/width but different JSON key order / whitespace
payload_b =
%{width: 480, hash: asset.hash}
|> JSON.encode!()
|> Base.url_encode64(padding: false)
# First request populates the cache
conn_a = get(conn, ~p"/assets/#{payload_a}")
assert conn_a.status == 200
assert get_resp_header(conn_a, "etag") == [payload_a]
# Second request with different payload but same canonical key
# should serve from cache (same content, correct ETag for this payload)
conn_b = get(conn, ~p"/assets/#{payload_b}")
assert conn_b.status == 200
assert get_resp_header(conn_b, "etag") == [payload_b]
# Both should return the same image content
assert conn_a.resp_body == conn_b.resp_body
end
@tag :capture_log
test "404s when asset content is corrupt", %{conn: conn} do
{:ok, asset} = Assets.store(%{content: "not an image", format: "image/jpeg"})