diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index c7ee47c6e..11f041c57 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -59,7 +59,9 @@ defmodule Pleroma.ReverseProxy do * `inline_content_types`: * `true` will not alter `content-disposition` (up to the upstream), * `false` will add `content-disposition: attachment` to any request, - * a list of whitelisted content types + * a list of whitelisted content types for which `content-disposition: inline` + is always set (overriding any upstream header) so the media can be embedded + in pages; the filename is derived from the content type * `req_headers`, `resp_headers` additional headers. @@ -386,7 +388,27 @@ defmodule Pleroma.ReverseProxy do List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) else - headers + if opt == true do + headers + else + name = inline_filename(content_type) + + disposition = + if name do + "inline; filename=\"#{name}\"" + else + "inline" + end + + List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + end + end + end + + defp inline_filename(content_type) do + case MIME.extensions(content_type) do + [ext | _] when ext != "" -> "inline.#{ext}" + _ -> nil end end diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex new file mode 100644 index 000000000..19215b627 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer do + @moduledoc """ + Best-effort MIME sniffing for remote attachments whose declared type is + unusable (missing or `application/octet-stream`). + + Fetches the first chunk of the URL and runs it through libmagic. Only + `image/*` results are returned; anything else, or any failure, yields `nil` — + so this never reclassifies non-image media and never blocks ingestion. This + exists because some remotes (e.g. extensionless Cloudflare Images URLs) ship + attachments with no usable `mediaType`, which would otherwise render in the + timeline as a clickable link instead of an inline image. + """ + + alias Pleroma.HTTP + + require Logger + + # libmagic only needs the file header; a few KB is plenty and keeps the + # request cheap even when the origin ignores Range. + @sniff_bytes 8 * 1024 + @timeout 5_000 + + @spec sniff_image_type(binary() | nil) :: {:ok, binary() | nil} + def sniff_image_type(url) when is_binary(url) and url != "" do + do_sniff(url) + rescue + e -> + Logger.debug( + "Attachment type sniff failed for #{url}: #{Exception.format(:error, e, __STACKTRACE__)}" + ) + + {:ok, nil} + end + + def sniff_image_type(_), do: {:ok, nil} + + defp do_sniff(url) do + headers = [{"range", "bytes=0-#{@sniff_bytes - 1}"}] + opts = [pool: :media, timeout: @timeout, recv_timeout: @timeout] + + with {:ok, %Tesla.Env{status: status, body: body}} when status in 200..299 <- + HTTP.get(url, headers, opts), + false <- blank?(body), + {:ok, %{mime_type: mime}} <- Majic.perform({:bytes, body}, pool: Pleroma.MajicPool), + true <- String.starts_with?(mime, "image/") do + {:ok, mime} + else + _ -> {:ok, nil} + end + end + + defp blank?(nil), do: true + defp blank?(""), do: true + defp blank?(_), do: false +end diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 5ee9e7549..b6e30a438 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer import Ecto.Changeset @@ -61,9 +62,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do end def fix_media_type(data) do - Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream") + current = data["mediaType"] || data["mimeType"] || "application/octet-stream" + + resolved = + if sniffable?(current) do + case AttachmentTypeSniffer.sniff_image_type(data["href"]) do + {:ok, mime} when is_binary(mime) -> mime + _ -> current + end + else + current + end + + Map.put(data, "mediaType", resolved) end + # Only sniff when the declared type carries no real information. Everything + # else (including unrecognized-but-present types) is left as the remote sent it. + defp sniffable?("application/octet-stream"), do: true + defp sniffable?(""), do: true + defp sniffable?(_), do: false + defp handle_href(href, mediaType, data) do [ %{ diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index ec4470379..5f75f2a07 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -298,21 +298,21 @@ defmodule Pleroma.ReverseProxyTest do end end - defp disposition_headers_mock(headers) do + defp disposition_headers_mock(headers, url \\ "/disposition") do ClientMock - |> expect(:request, fn :get, "/disposition", _, _, _ -> - Registry.register(ClientMock, "/disposition", 0) + |> expect(:request, fn :get, ^url, _, _, _ -> + Registry.register(ClientMock, url, 0) - {:ok, 200, headers, %{url: "/disposition"}} + {:ok, 200, headers, %{url: url}} end) - |> expect(:stream_body, 2, fn %{url: "/disposition"} = client -> - case Registry.lookup(ClientMock, "/disposition") do + |> expect(:stream_body, 2, fn %{url: ^url} = client -> + case Registry.lookup(ClientMock, url) do [{_, 0}] -> - Registry.update_value(ClientMock, "/disposition", &(&1 + 1)) + Registry.update_value(ClientMock, url, &(&1 + 1)) {:ok, "", client} [{_, 1}] -> - Registry.unregister(ClientMock, "/disposition") + Registry.unregister(ClientMock, url) :done end end) @@ -328,6 +328,40 @@ defmodule Pleroma.ReverseProxyTest do conn = ReverseProxy.call(conn, "/disposition") assert {"content-type", "image/gif"} in conn.resp_headers + assert {"content-disposition", "inline; filename=\"inline.gif\""} in conn.resp_headers + end + + test "forces inline for inline content types overriding upstream attachment", %{ + conn: conn + } do + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-disposition", "attachment; filename=\"filename.png\""}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition") + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert String.starts_with?(disposition, "inline") + refute String.starts_with?(disposition, "attachment") + end + + test "forces inline based on content type even without a file extension in the url", %{ + conn: conn + } do + disposition_headers_mock( + [ + {"content-type", "image/jpeg"}, + {"content-length", "0"} + ], + "/original" + ) + + conn = ReverseProxy.call(conn, "/original") + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert disposition == "inline; filename=\"inline.jpg\"" end test "with content-disposition header", %{conn: conn} do @@ -340,6 +374,132 @@ defmodule Pleroma.ReverseProxyTest do assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers end + + test "with inline_content_types: true leaves upstream headers untouched", %{ + conn: conn + } do + # opt == true: the proxy must not synthesise or rewrite content-disposition. + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-disposition", "attachment; filename=\"upstream.png\""}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true) + + assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers + end + + test "with inline_content_types: true does not synthesise inline when upstream is absent", %{ + conn: conn + } do + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true) + + assert Conn.get_resp_header(conn, "content-disposition") == [] + end + + test "with inline_content_types: false forces attachment for everything", %{ + conn: conn + } do + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-disposition", "inline; filename=\"pic.png\""}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false) + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert String.starts_with?(disposition, "attachment") + end + + test "with inline_content_types: false derives attachment filename from the URL basename when no upstream filename", %{ + conn: conn + } do + # No content-disposition header: the attachment branch falls back to + # attachment_name, which by default comes from MediaProxy.filename/1 + # (the URL basename). + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false) + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert disposition == "attachment; filename=\"disposition\"" + end + + test "with inline_content_types: false honours an explicit attachment_name opt", %{ + conn: conn + } do + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-length", "0"} + ]) + + conn = + ReverseProxy.call(conn, "/disposition", + inline_content_types: false, + attachment_name: "custom.bin" + ) + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert disposition == "attachment; filename=\"custom.bin\"" + end + + test "forces bare inline for a whitelisted type with no MIME extension", %{ + conn: conn + } do + # image/x-foo-bar has no entry in MIME's database, so inline_filename/1 + # returns nil and the disposition should be the bare token "inline". + disposition_headers_mock([ + {"content-type", "image/x-foo-bar"}, + {"content-length", "0"} + ]) + + conn = ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/x-foo-bar"]) + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert disposition == "inline" + end + + test "honours a custom inline_content_types whitelist", %{conn: conn} do + # image/bmp is NOT in the default whitelist; with a custom whitelist + # that includes it, the proxy should force inline. + disposition_headers_mock([ + {"content-type", "image/bmp"}, + {"content-disposition", "attachment; filename=\"upstream.bmp\""}, + {"content-length", "0"} + ]) + + conn = + ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"]) + + [disposition] = Conn.get_resp_header(conn, "content-disposition") + assert String.starts_with?(disposition, "inline") + assert String.ends_with?(disposition, "inline.bmp\"") + end + + test "treats a type outside the whitelist as attachment even on custom whitelist", %{ + conn: conn + } do + disposition_headers_mock([ + {"content-type", "image/png"}, + {"content-disposition", "attachment; filename=\"filename.png\""}, + {"content-length", "0"} + ]) + + conn = + ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"]) + + assert {"content-disposition", "attachment; filename=\"filename.png\""} in conn.resp_headers + end end describe "content-type sanitisation" do diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs new file mode 100644 index 000000000..52b1b67a1 --- /dev/null +++ b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs @@ -0,0 +1,178 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSnifferTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer + + # @sniff_bytes must mirror the value in the module under test; we assert the + # Range header that the sniffer sends, so the test should fail loudly if the + # constant drifts. + @sniff_bytes 8 * 1024 + @range_header "bytes=0-#{@sniff_bytes - 1}" + + describe "sniff_image_type/1 with a real image body" do + test "returns image/jpeg for a JPEG body" do + url = "https://example.com/media/image.jpg" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: jpeg} + end) + + assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns image/png for a PNG body" do + url = "https://example.com/media/image.png" + png = File.read!("test/fixtures/image.png") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: png} + end) + + assert {:ok, "image/png"} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns image/gif for a GIF body" do + url = "https://example.com/media/image.gif" + gif = File.read!("test/fixtures/image.gif") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: gif} + end) + + assert {:ok, "image/gif"} = AttachmentTypeSniffer.sniff_image_type(url) + end + end + + describe "sniff_image_type/1 sends a ranged request" do + test "sends a Range header asking for the first chunk" do + url = "https://example.com/media/ranged" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn + %{method: :get, url: ^url, headers: headers} -> + headers_map = Enum.into(headers, %{}) + assert headers_map["range"] == @range_header + + %Tesla.Env{status: 200, body: jpeg} + + %{method: :get, url: ^url} -> + # If the sniffer did not send the Range header, fail explicitly. + flunk("expected Range header to be sent with the request") + end) + + assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url) + end + end + + describe "sniff_image_type/1 with a non-image body" do + test "returns nil for plain text" do + url = "https://example.com/media/note.txt" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: "just some plain text, not an image at all"} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil for an HTML body" do + url = "https://example.com/page" + html = File.read!("test/fixtures/rel_me_anchor.html") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: html} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil for an empty body" do + url = "https://example.com/media/empty" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: ""} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil for a nil body" do + url = "https://example.com/media/nil-body" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: nil} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + end + + describe "sniff_image_type/1 on HTTP failure" do + test "returns nil on a 404" do + url = "https://example.com/media/missing" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 404, body: ""} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil on a 500" do + url = "https://example.com/media/oops" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 500, body: "server error"} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil on a 3xx (not in 200..299)" do + url = "https://example.com/media/redirect" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 302, body: ""} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil on a transport-level error" do + url = "https://example.com/media/econnrefused" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + {:error, :econnrefused} + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + + test "returns nil when the HTTP adapter raises" do + url = "https://example.com/media/raises" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + raise "boom" + end) + + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) + end + end + + describe "sniff_image_type/1 guard clauses" do + test "returns nil for nil input without making a request" do + # No Tesla mock is configured; if a request were made, Tesla.Mock would + # raise and fail the test. + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(nil) + end + + test "returns nil for an empty-string URL without making a request" do + assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type("") + end + end +end diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs index 744ae8704..870ad90cc 100644 --- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs @@ -215,5 +215,226 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do AttachmentValidator.cast_and_validate(attachment) |> Ecto.Changeset.apply_action(:insert) end + + test "sniffs image/jpeg for octet-stream attachments whose body is an image" do + url = "https://example.com/media/no-extension/original" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: jpeg} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "application/octet-stream", + "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url + end + + test "sniffs image/jpeg for attachments with a missing mediaType" do + url = "https://example.com/media/missing-type/original" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: jpeg} + end) + + attachment = %{ + "type" => "Document", + "url" => [%{"type" => "Link", "href" => url}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url + end + + test "leaves non-image octet-stream attachments as octet-stream" do + url = "https://example.com/media/some-document" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: "just some plain text, not an image at all"} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "application/octet-stream", + "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{mediaType: "application/octet-stream"}] = attachment.url + end + + test "does not sniff when the remote already provided a real mediaType" do + # No Tesla mock is set up: if the validator tried to fetch, Tesla.Mock + # would raise and fail the test. A real image type must be kept as-is. + attachment = %{ + "type" => "Document", + "mediaType" => "image/png", + "url" => [ + %{"type" => "Link", "href" => "https://example.com/x", "mediaType" => "image/png"} + ] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{mediaType: "image/png"}] = attachment.url + end + end + + describe "fix_media_type fallbacks and sniffing" do + test "uses mimeType as a fallback when mediaType is absent (real type, not sniffed)" do + # No Tesla mock: if the validator tried to fetch, the test would fail. + # A real image type coming from mimeType must be preserved as-is on the + # outer attachment and on the synthesised url entry (string url form). + attachment = %{ + "type" => "Document", + "mimeType" => "image/png", + "url" => "https://example.com/x.png" + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "image/png" + assert [%{mediaType: "image/png"}] = attachment.url + end + + test "prefers mediaType over mimeType when both are present" do + attachment = %{ + "type" => "Document", + "mediaType" => "image/gif", + "mimeType" => "image/png", + "url" => "https://example.com/x.gif" + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "image/gif" + assert [%{mediaType: "image/gif"}] = attachment.url + end + + test "sniffs when the url entry's mediaType is application/octet-stream and the body is an image" do + # The outer attachment mediaType is not sniffed (no top-level href); only + # the url entry is. We assert only on the url entry. + url = "https://example.com/media/mime-type-octet" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: jpeg} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "application/octet-stream", + "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{mediaType: "image/jpeg"}] = attachment.url + end + + test "sniffs when mediaType is the empty string and the body is an image" do + url = "https://example.com/media/empty-mediatype" + png = File.read!("test/fixtures/image.png") + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 200, body: png} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "", + "url" => [%{"type" => "Link", "href" => url, "mediaType" => ""}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{mediaType: "image/png"}] = attachment.url + end + + test "falls back to the declared type when the sniffer HTTP request fails" do + url = "https://example.com/media/sniff-404" + + Tesla.Mock.mock(fn %{method: :get, url: ^url} -> + %Tesla.Env{status: 404, body: ""} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "application/octet-stream", + "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [%{mediaType: "application/octet-stream"}] = attachment.url + end + + test "sniffs each url entry independently" do + jpeg_url = "https://example.com/media/a" + octet_url = "https://example.com/media/b" + jpeg = File.read!("test/fixtures/image.jpg") + + Tesla.Mock.mock(fn + %{method: :get, url: ^jpeg_url} -> + %Tesla.Env{status: 200, body: jpeg} + + %{method: :get, url: ^octet_url} -> + %Tesla.Env{status: 200, body: "definitely not an image"} + end) + + attachment = %{ + "type" => "Document", + "mediaType" => "application/octet-stream", + "url" => [ + %{"type" => "Link", "href" => jpeg_url, "mediaType" => "application/octet-stream"}, + %{"type" => "Link", "href" => octet_url, "mediaType" => "application/octet-stream"} + ] + } + + {:ok, attachment} = + attachment + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert [ + %{href: ^jpeg_url, mediaType: "image/jpeg"}, + %{href: ^octet_url, mediaType: "application/octet-stream"} + ] = attachment.url + end end end