Merge branch 'develop' into fix/card-skip-quote-link

This commit is contained in:
lain 2026-11-03 18:24:23 +00:00
commit 2c872cf474
808 changed files with 7673 additions and 2171 deletions

View file

@ -70,7 +70,7 @@
"preferredUsername": "jk.nipponalba.scot",
"summary": "",
"publicKey": {
"id": "jk.nipponalba.scot",
"id": "https://fed.brid.gy/jk.nipponalba.scot#key",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdarxwzxnNbJ2hneWOYHkYJowk\npyigQtxlUd0VjgSQHwxU9kWqfbrHBVADyTtcqi/4dAzQd3UnCI1TPNnn4LPZY9PW\noiWd3Zl1/EfLFxO7LU9GS7fcSLQkyj5JNhSlN3I8QPudZbybrgRDVZYooDe1D+52\n5KLGqC2ajrIVOiDRTQIDAQAB\n-----END PUBLIC KEY-----"
},
"inbox": "https://fed.brid.gy/jk.nipponalba.scot/inbox",

BIN
test/fixtures/dji_gps.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
test/fixtures/strip_location_gps.heic vendored Normal file

Binary file not shown.

BIN
test/fixtures/strip_location_gps.tiff vendored Normal file

Binary file not shown.

BIN
test/fixtures/strip_location_gps.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

View file

@ -11,7 +11,7 @@
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji"
}],
"id": "http://mastodon.example.org/users/admin",
"id": "http://mastodon.example.org/users/relay",
"type": "Application",
"invisible": true,
"following": "http://mastodon.example.org/users/admin/following",
@ -24,8 +24,8 @@
"url": "http://mastodon.example.org/@admin",
"manuallyApprovesFollowers": false,
"publicKey": {
"id": "http://mastodon.example.org/users/admin#main-key",
"owner": "http://mastodon.example.org/users/admin",
"id": "http://mastodon.example.org/users/relay#main-key",
"owner": "http://mastodon.example.org/users/relay",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtc4Tir+3ADhSNF6VKrtW\nOU32T01w7V0yshmQei38YyiVwVvFu8XOP6ACchkdxbJ+C9mZud8qWaRJKVbFTMUG\nNX4+6Q+FobyuKrwN7CEwhDALZtaN2IPbaPd6uG1B7QhWorrY+yFa8f2TBM3BxnUy\nI4T+bMIZIEYG7KtljCBoQXuTQmGtuffO0UwJksidg2ffCF5Q+K//JfQagJ3UzrR+\nZXbKMJdAw4bCVJYs4Z5EhHYBwQWiXCyMGTd7BGlmMkY6Av7ZqHKC/owp3/0EWDNz\nNqF09Wcpr3y3e8nA10X40MJqp/wR+1xtxp+YGbq/Cj5hZGBG7etFOmIpVBrDOhry\nBwIDAQAB\n-----END PUBLIC KEY-----\n"
},
"attachment": [{

BIN
test/fixtures/xmp_gps.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -95,7 +95,7 @@ defmodule Mix.Tasks.Pleroma.InstanceTest do
assert generated_config =~ "http: [ip: {127, 0, 0, 1}, port: 4000]"
assert generated_config =~
"filters: [Pleroma.Upload.Filter.Exiftool.StripLocation, Pleroma.Upload.Filter.Exiftool.ReadDescription]"
"filters: [Pleroma.Upload.Filter.Exif.StripLocation, Pleroma.Upload.Filter.Exif.ReadDescription]"
assert File.read!(tmp_path() <> "setup.psql") == generated_setup_psql()
assert File.exists?(Path.expand("./test/instance/static/robots.txt"))

View file

@ -13,40 +13,39 @@ defmodule Pleroma.Config.DeprecationWarningsTest do
describe "filter exiftool" do
test "gives warning when still used" do
clear_config(
[Pleroma.Upload, :filters],
[Pleroma.Upload.Filter.Exiftool]
)
assert capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool]
```
Is now
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool.StripLocation]
```
"""
end
test "changes setting to exiftool strip location" do
clear_config(
[Pleroma.Upload, :filters],
[Pleroma.Upload.Filter.Exiftool, Pleroma.Upload.Filter.Exiftool.ReadDescription]
)
log = capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end)
assert log =~ "!!!DEPRECATION WARNING!!!"
assert log =~
"* `Pleroma.Upload.Filter.Exiftool` is now `Pleroma.Upload.Filter.Exif.StripLocation`"
assert log =~
"* `Pleroma.Upload.Filter.Exiftool.ReadDescription` is now `Pleroma.Upload.Filter.Exif.ReadDescription`"
refute log =~ "Exiftool.StripLocation`"
end
test "changes setting to the Exif filters" do
clear_config(
[Pleroma.Upload, :filters],
[
Pleroma.Upload.Filter.Dedupe,
Pleroma.Upload.Filter.Exiftool,
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
]
)
expected_config = [
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
Pleroma.Upload.Filter.Dedupe,
Pleroma.Upload.Filter.Exif.StripLocation,
Pleroma.Upload.Filter.Exif.ReadDescription
]
capture_log(fn -> DeprecationWarnings.warn() end)
@ -58,8 +57,8 @@ defmodule Pleroma.Config.DeprecationWarningsTest do
clear_config(
[Pleroma.Upload, :filters],
[
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
Pleroma.Upload.Filter.Exif.StripLocation,
Pleroma.Upload.Filter.Exif.ReadDescription
]
)

View file

@ -96,6 +96,29 @@ defmodule Pleroma.Config.TransferTaskTest do
assert Application.get_env(:pleroma, Pleroma.Search.ParadeDB.Repo)[:pool_size] == 2
end
test "skips settings with an unknown module key" do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Repo.insert_all("config", [
%{
group: ":pleroma",
key: "Pleroma.Nowhere.Module",
value: :erlang.term_to_binary(enabled: true),
inserted_at: now,
updated_at: now
}
])
insert(:config, key: :test_key, value: [live: 2])
on_exit(fn -> Application.delete_env(:pleroma, :test_key) end)
log = capture_log(fn -> assert :ok = TransferTask.load_and_update_env([], false) end)
assert log =~ "Pleroma.Nowhere.Module"
assert Application.get_env(:pleroma, :test_key) == [live: 2]
refute Application.get_env(:pleroma, :invalid_atom)
end
describe "pleroma restart" do
setup do
on_exit(fn ->

View file

@ -50,6 +50,22 @@ defmodule Pleroma.ConfigDBTest do
assert config[:pleroma][:instance] == [name: "Pleroma"]
end
test "loads a row with an unknown module key as :invalid_atom" do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Repo.insert_all("config", [
%{
group: ":pleroma",
key: "Pleroma.Nowhere.Module",
value: :erlang.term_to_binary(enabled: true),
inserted_at: now,
updated_at: now
}
])
assert [%ConfigDB{group: :pleroma, key: :invalid_atom}] = Repo.all(ConfigDB)
end
describe "update_or_create/1" do
test "common" do
config = insert(:config)

View file

@ -52,4 +52,42 @@ defmodule Pleroma.Emails.MailerTest do
subject: "Pleroma test email"
)
end
test "keeps certificate checks for HTTP API adapters" do
Mailer.deliver(@email)
assert_received {:email, %Swoosh.Email{private: %{hackney_options: hackney_options}}}
assert hackney_options[:ssl_options] ==
Pleroma.HTTP.TLS.ssl_options()
end
describe "put_mua_cacertfile/1" do
test "makes Mua trust the CAStore bundle" do
config = Mailer.put_mua_cacertfile(adapter: Swoosh.Adapters.Mua, relay: "mail.example.com")
assert config[:ssl] == [cacertfile: CAStore.file_path()]
assert config[:relay] == "mail.example.com"
end
test "keeps other ssl options" do
config = Mailer.put_mua_cacertfile(adapter: Swoosh.Adapters.Mua, ssl: [depth: 3])
assert config[:ssl] == [cacertfile: CAStore.file_path(), depth: 3]
end
test "keeps a CA that the admin set" do
config = [adapter: Swoosh.Adapters.Mua, ssl: [cacertfile: "/etc/ssl/smtp.pem"]]
assert Mailer.put_mua_cacertfile(config) == config
config = [adapter: Swoosh.Adapters.Mua, ssl: [cacerts: []]]
assert Mailer.put_mua_cacertfile(config) == config
end
test "leaves other adapters alone" do
config = [adapter: Swoosh.Adapters.SMTP, relay: "mail.example.com"]
assert Mailer.put_mua_cacertfile(config) == config
end
end
end

View file

@ -190,4 +190,66 @@ defmodule Pleroma.FilterTest do
{:error, _} = Filter.update(filter, changes)
end
describe "compose_regex/2" do
defp filters(phrases), do: Enum.map(phrases, &%Filter{phrase: &1})
defp pg_match?(regex, text) do
%{rows: [[match]]} = Repo.query!("SELECT $1::text ~* $2", [text, regex])
match
end
test "treats phrases as literal text in Postgres" do
regex = Filter.compose_regex(filters(["a.b", "(c"]))
assert pg_match?(regex, "see a.b here")
refute pg_match?(regex, "see axb here")
assert pg_match?(regex, "see (c here")
end
test "treats phrases as literal text in Elixir" do
regex = Filter.compose_regex(filters(["a.b", "(c"]), :re)
assert Regex.match?(regex, "see a.b here")
refute Regex.match?(regex, "see axb here")
assert Regex.match?(regex, "see (c here")
end
test "keeps word boundaries for every phrase in Postgres" do
regex = Filter.compose_regex(filters(["cat", "dog"]))
assert pg_match?(regex, "a dog barks")
refute pg_match?(regex, "a catalog")
refute pg_match?(regex, "a hotdog")
end
test "only adds word boundaries next to word characters" do
pg = Filter.compose_regex(filters(["#tag"]))
re = Filter.compose_regex(filters(["#tag"]), :re)
for {text, expected} <- [{"a #tag here", true}, {"a#tag", true}, {"some #tags", false}] do
assert pg_match?(pg, text) == expected
assert Regex.match?(re, text) == expected
end
end
test "matches inside words when whole_word is false" do
filters = [%Filter{phrase: "cat", whole_word: false}, %Filter{phrase: "dog"}]
pg = Filter.compose_regex(filters)
re = Filter.compose_regex(filters, :re)
for {text, expected} <- [{"a catalog", true}, {"a hotdog", false}, {"a dog", true}] do
assert pg_match?(pg, text) == expected
assert Regex.match?(re, text) == expected
end
end
test "keeps word boundaries for every phrase in Elixir" do
regex = Filter.compose_regex(filters(["cat", "dog"]), :re)
assert Regex.match?(regex, "a dog barks")
refute Regex.match?(regex, "a catalog")
refute Regex.match?(regex, "a hotdog")
end
end
end

View file

@ -7,6 +7,7 @@ defmodule Pleroma.FormatterTest do
alias Pleroma.User
use Pleroma.DataCase
import Mock
import Pleroma.Factory
setup_all do
@ -372,4 +373,99 @@ defmodule Pleroma.FormatterTest do
assert Formatter.html_escape(text, "text/plain") == expected
end
describe "markdown_to_html/2" do
test "keeps formatted link text when stripping email autolinks" do
opts = %{breaks: true, strip_email_autolinks: true}
assert Formatter.markdown_to_html("[**hello**](https://example.org/)", opts) ==
~s(<p><a href="https://example.org/"><strong>hello</strong></a></p>)
assert Formatter.markdown_to_html("[`code`](https://example.org/)", opts) ==
~s(<p><a href="https://example.org/"><code class="inline">code</code></a></p>)
end
test "escapes link attribute values" do
markdown = ~s|[click](http://example.com/?a=x" onerror="alert(1))|
# The quotes are percent-encoded, so everything stays inside href.
assert Formatter.markdown_to_html(markdown) ==
~s|<p><a href="http://example.com/?a=x%22 onerror=%22alert(1)">click</a></p>|
end
test "preserves entities without rendering dangerous raw HTML" do
markdown = "safe &amp; sound<script>alert(1)</script>"
assert Formatter.markdown_to_html(markdown) == "<p>safe &amp; sound</p>"
assert Formatter.markdown_to_html("<div><script>alert(1)</script>safe</div>") ==
"<div>safe</div>"
assert Formatter.markdown_to_html("<ScRiPt>alert(1)</ScRiPt>") == ""
assert Formatter.markdown_to_html("<BR>") == "<BR>"
end
test "escapes dangerous HTML inside code" do
markdown = "`<script>&amp;</script>`"
assert Formatter.markdown_to_html(markdown) ==
~s(<p><code class="inline">&lt;script&gt;&amp;amp;&lt;/script&gt;</code></p>)
end
test "rejects entity-encoded URL delimiters" do
markdown = "[click](http://example.com/?a=x&#34;onerror=alert(1))"
assert Formatter.markdown_to_html(markdown) ==
~s|<p><a href="http://example.com/?a=x%22onerror=alert(1)">click</a></p>|
end
test "keeps URLs with encoded quotes complete" do
markdown = "[search](https://example.org/?q=&quot;cat&quot;&page=2)"
assert Formatter.markdown_to_html(markdown) ==
~s(<p><a href="https://example.org/?q=%22cat%22&amp;page=2">search</a></p>)
end
test "strips email autolinks without changing explicit mailto links" do
opts = %{strip_email_autolinks: true}
assert Formatter.markdown_to_html("<foo@example.com>", opts) ==
"<p>foo@example.com</p>"
assert Formatter.markdown_to_html("[email](mailto:foo@example.com)", opts) ==
~s(<p><a href="mailto:foo@example.com">email</a></p>)
end
test "handles malformed Markdown that crashed older EarmarkParser releases" do
assert Formatter.markdown_to_html("\\[{:}") == "<p>[{:}</p>"
end
test "handles a malformed Markdown regression corpus" do
markdown_samples = [
"- hello\n{:.claass .klass}",
"[link](\"",
"<script",
"</style>",
"~~~",
"<![CDATA[",
<<0>>
]
for markdown <- markdown_samples do
html = Formatter.markdown_to_html(markdown)
assert is_binary(html)
refute html =~ <<0>>
end
end
test "falls back to escaped text when parsing fails" do
markdown = "<script>alert(1)</script>\nnext"
with_mock EarmarkParser, as_ast: fn _text, _opts -> raise "parser failed" end do
assert Formatter.markdown_to_html(markdown, %{breaks: true}) ==
"<p>&lt;script&gt;alert(1)&lt;/script&gt;<br>next</p>"
end
end
end
end

View file

@ -17,6 +17,11 @@ defmodule Pleroma.FrontendTest do
end)
end
test "admin-fe uses package builds" do
assert Pleroma.Config.get([:frontends, :available, "admin-fe", "build_url"]) ==
"https://git.pleroma.social/api/packages/pleroma/generic/admin-fe-builds/${ref}/latest.zip"
end
test "it downloads and unzips a known frontend" do
clear_config([:frontends, :available], %{
"pleroma" => %{

View file

@ -42,6 +42,7 @@ defmodule Pleroma.Gun.ConnTest do
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8080, opts ->
assert opts.transport == :tcp
assert opts.supervise
refute Map.has_key?(opts, :tls_opts)
{:ok, conn}
end)
@ -172,6 +173,67 @@ defmodule Pleroma.Gun.ConnTest do
)
end
describe "IPv6-only hosts" do
@nxdomain {:error, {:down, {:shutdown, :nxdomain}}}
test "retries once over IPv6 when the IPv4 lookup finds no address" do
ipv4_conn = spawn_link(fn -> Process.sleep(:infinity) end)
ipv6_conn = spawn_link(fn -> Process.sleep(:infinity) end)
expect(Pleroma.GunMock, :open, 2, fn
~c"v6only.example", 443, %{tcp_opts: tcp_opts} = opts ->
assert :inet6 in tcp_opts
assert opts.transport == :tls
assert opts.tls_opts[:verify] == :verify_peer
{:ok, ipv6_conn}
~c"v6only.example", 443, opts ->
refute Map.has_key?(opts, :tcp_opts)
# A linked Gun process would take the caller down with the lookup failure.
assert opts.supervise
{:ok, ipv4_conn}
end)
stub(Pleroma.GunMock, :await_up, fn
^ipv4_conn, _ -> @nxdomain
^ipv6_conn, _ -> {:ok, :http2}
end)
assert {:ok, ^ipv6_conn, :http2, nil} =
Conn.open(URI.parse("https://v6only.example/inbox"), [])
stop_conn(ipv4_conn)
stop_conn(ipv6_conn)
end
test "does not retry IP literals or other errors" do
expect(Pleroma.GunMock, :open, 2, fn _, _, _ ->
{:ok, spawn(fn -> Process.sleep(:infinity) end)}
end)
stub(Pleroma.GunMock, :await_up, fn _, _ -> @nxdomain end)
assert @nxdomain = Conn.open(URI.parse("https://192.0.2.1/inbox"), [])
stub(Pleroma.GunMock, :await_up, fn _, _ ->
{:error, {:down, {:shutdown, :econnrefused}}}
end)
assert {:error, _} = Conn.open(URI.parse("https://v6only.example/inbox"), [])
end
test "inet6_fallback/3 keeps configured TCP options and skips hosts already on IPv6" do
assert {:ok, %{tcp_opts: [:inet6, nodelay: true]}} =
Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [nodelay: true]})
assert {:ok, %{tcp_opts: [:inet6, send_timeout: 15_000, send_timeout_close: true]}} =
Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{})
assert :error = Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [:inet6]})
assert :error = Conn.inet6_fallback(@nxdomain, {192, 0, 2, 1}, %{})
assert :error = Conn.inet6_fallback({:error, :timeout}, ~c"v6only.example", %{})
end
end
defp stop_conn(conn) do
Process.unlink(conn)
Process.exit(conn, :kill)

View file

@ -141,6 +141,44 @@ defmodule Pleroma.HTMLTest do
end
end
describe "link schemes as a browser sees them" do
# Browsers remove tabs and newlines in URLs and control characters and
# spaces at the start, so these are javascript: links to them.
test "removes javascript: links hidden with control characters" do
for href <- [
"java\tscript:alert(1)",
"java&#x09;script:alert(1)",
"java\nscript:alert(1)",
"&#x0d;javascript:alert(1)",
" javascript:alert(1)",
"\u0001javascript:alert(1)"
],
scrubber <- [
Pleroma.HTML.Scrubber.Default,
Pleroma.HTML.Scrubber.TwitterText,
Pleroma.HTML.Scrubber.LinksOnly
] do
html = HTML.filter_tags(~s(<a href="#{href}">x</a>), scrubber)
refute html =~ "href", "#{inspect(href)} survived #{inspect(scrubber)}: #{html}"
end
end
test "keeps normal links" do
for href <- ["https://example.org/a?b=c", "/relative/path", "#fragment"] do
assert HTML.filter_tags(~s(<a href="#{href}">x</a>), Pleroma.HTML.Scrubber.Default) ==
~s(<a href="#{href}">x</a>)
end
end
test "removes images with hidden schemes" do
html =
HTML.filter_tags(~s|<img src="java\tscript:alert(1)"/>|, Pleroma.HTML.Scrubber.Default)
refute html =~ "src"
end
end
describe "default scrubber" do
test "normalizes HTML as expected" do
expected = """

View file

@ -50,6 +50,15 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do
assert Keyword.has_key?(defaults, :b)
end
test "uses Hackney's ssl_options from the config as Gun's tls_opts" do
clear_config([:http, :adapter], ssl_options: [versions: [:"tlsv1.3"]])
opts = Gun.options([], URI.parse("https://example.com"))
assert opts[:tls_opts] == [versions: [:"tlsv1.3"]]
refute Keyword.has_key?(opts, :ssl_options)
end
test "parses string proxy host & port" do
clear_config([:http, :proxy_url], "localhost:8123")

View file

@ -13,6 +13,12 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
{:ok, uri: uri}
end
# Hackney 4 shares one HTTP/2 connection per host, and closing a response
# stops that connection for every stream on it.
test "Hackney only speaks HTTP/1.1" do
assert :hackney_util.default_protocols() == [:http1]
end
describe "options/2" do
setup do: clear_config([:http, :adapter], a: 1, b: 2)
@ -24,6 +30,13 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
assert opts[:with_body] == true
end
test "does not allow connection options to enable Hackney redirects", %{uri: uri} do
opts = Hackney.options([follow_redirect: true, force_redirect: true], uri)
assert opts[:follow_redirect] == false
assert opts[:force_redirect] == false
end
test "add proxy and opts from config", %{uri: uri} do
opts = Hackney.options([proxy: "localhost:8123"], uri)
@ -32,6 +45,26 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
assert opts[:proxy] == "localhost:8123"
end
test "gives Hackney a string host for a proxy with an IP address", %{uri: uri} do
clear_config([:http, :proxy_url], {:socks5, {127, 0, 0, 1}, 9050})
assert Hackney.options([], uri)[:proxy] == {:socks5, ~c"127.0.0.1", 9050}
clear_config([:http, :proxy_url], {{10, 0, 0, 1}, 8123})
assert Hackney.options([], uri)[:proxy] == {~c"10.0.0.1", 8123}
clear_config([:http, :proxy_url], nil)
assert Hackney.options([proxy: {:socks5, {0, 0, 0, 0, 0, 0, 0, 1}, 9050}], uri)[:proxy] ==
{:socks5, ~c"::1", 9050}
end
# AdminFE stores an empty field as "", and Hackney 4 fails every request with it.
test "ignores an empty proxy_url", %{uri: uri} do
clear_config([:http, :proxy_url], "")
refute Keyword.has_key?(Hackney.options([], uri), :proxy)
end
test "respect connection opts and no proxy", %{uri: uri} do
opts = Hackney.options([a: 2, b: 1], uri)
@ -39,5 +72,20 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
assert opts[:b] == 1
refute Keyword.has_key?(opts, :proxy)
end
test "trusts the CAStore bundle", %{uri: uri} do
opts = Hackney.options([], uri)
assert opts[:ssl_options] == Pleroma.HTTP.TLS.ssl_options()
end
test "keeps the CAStore bundle with configured ssl_options", %{uri: uri} do
clear_config([:http, :adapter], ssl_options: [versions: [:"tlsv1.2"]])
opts = Hackney.options([], uri)
assert opts[:ssl_options][:versions] == [:"tlsv1.2"]
assert opts[:ssl_options][:cacertfile] == CAStore.file_path()
end
end
end

View file

@ -0,0 +1,35 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.AdapterHelper.TeslaAdapterTest do
use ExUnit.Case, async: false
alias Pleroma.HTTP.AdapterHelper
alias Pleroma.HTTP.TLS
setup do
adapter = Application.get_env(:tesla, :adapter)
on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end)
end
test "adds the TLS options to Hackney" do
Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney)
assert {Tesla.Adapter.Hackney, opts} = AdapterHelper.tesla_adapter()
assert opts[:ssl_options] == TLS.ssl_options()
assert opts[:proxy_ssl_options] == TLS.ssl_options()
end
test "turns on certificate checks for Gun" do
Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun)
assert AdapterHelper.tesla_adapter() == {Tesla.Adapter.Gun, certificates_verification: true}
end
test "keeps other adapters" do
Application.put_env(:tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch})
assert AdapterHelper.tesla_adapter() == {Tesla.Adapter.Finch, name: MyFinch}
end
end

View file

@ -12,6 +12,12 @@ defmodule Pleroma.HTTP.AdapterHelperTest do
assert AdapterHelper.format_proxy(nil) == nil
end
test "with an empty string" do
assert ExUnit.CaptureLog.capture_log(fn ->
assert AdapterHelper.format_proxy("") == nil
end) == ""
end
test "with string" do
assert AdapterHelper.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123}
end

View file

@ -0,0 +1,195 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Based on Akkoma's test/pleroma/http/backoff_test.exs
defmodule Pleroma.HTTP.BackoffTest do
use Pleroma.DataCase, async: false
alias Pleroma.HTTP.Backoff
@cache :http_backoff_cache
@url "https://ratelimited.example.com/users/alice"
@host "ratelimited.example.com"
defp mock_response(status, headers \\ []) do
test_pid = self()
Tesla.Mock.mock(fn %{method: :get} ->
send(test_pid, :request)
%Tesla.Env{status: status, headers: headers, body: ""}
end)
end
defp ttl_seconds do
{:ok, ttl} = Cachex.ttl(@cache, @host)
div(ttl, 1000)
end
defp assert_ttl_about(expected) do
ttl = ttl_seconds()
assert ttl in (expected - 15)..expected//1, "expected a TTL near #{expected}, got #{ttl}"
end
describe "get/3" do
test "returns the response when the host is not backed off" do
mock_response(200)
assert {:ok, %Tesla.Env{status: 200}} = Backoff.get(@url)
assert {:ok, nil} = Cachex.get(@cache, @host)
end
test "does not send a request while the host is backed off" do
Cachex.put(@cache, @host, true)
mock_response(200)
assert {:error, :ratelimit} = Backoff.get(@url)
refute_received :request
end
for status <- [429, 503] do
test "backs off from the host after a #{status} response" do
mock_response(unquote(status))
assert {:error, :ratelimit} = Backoff.get(@url)
assert_received :request
assert {:ok, true} = Cachex.get(@cache, @host)
assert {:error, :ratelimit} = Backoff.get(@url <> "/other")
refute_received :request
end
end
test "uses a default backoff without headers" do
mock_response(429)
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(300)
end
test "treats host names case-insensitively" do
mock_response(429)
assert {:error, :ratelimit} = Backoff.get("https://RateLimited.Example.com/users/alice")
assert_received :request
assert {:error, :ratelimit} = Backoff.get(@url)
refute_received :request
end
test "only backs off from the host that answered" do
mock_response(429)
assert {:error, :ratelimit} = Backoff.get(@url)
mock_response(200)
assert {:ok, _} = Backoff.get("https://other.example.com/users/bob")
end
test "backs off from the host that answered after a redirect" do
test_pid = self()
Tesla.Mock.mock(fn
%{url: "https://redirector.example.com/users/alice"} = env ->
%{env | status: 302, headers: [{"location", @url}], body: ""}
%{url: @url} = env ->
send(test_pid, :request)
%{env | status: 429, headers: [], body: ""}
end)
assert {:error, :ratelimit} = Backoff.get("https://redirector.example.com/users/alice")
assert {:ok, true} = Cachex.get(@cache, @host)
assert {:ok, nil} = Cachex.get(@cache, "redirector.example.com")
end
end
describe "backoff headers" do
test "parses Retry-After in seconds" do
mock_response(429, [{"retry-after", "600"}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "parses Retry-After as an HTTP-date" do
date =
DateTime.utc_now()
|> DateTime.add(600)
|> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
mock_response(503, [{"retry-after", date}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "parses Retry-After as an ISO 8601 timestamp" do
date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
mock_response(429, [{"retry-after", date}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "parses X-RateLimit-Reset as an ISO 8601 timestamp" do
date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
mock_response(429, [{"x-ratelimit-reset", date}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "parses X-RateLimit-Reset as a Unix timestamp" do
reset = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_unix() |> to_string()
mock_response(429, [{"x-ratelimit-reset", reset}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "parses X-RateLimit-Reset in seconds" do
mock_response(429, [{"x-ratelimit-reset", "600"}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "matches header names case-insensitively" do
mock_response(429, [{"Retry-After", "600"}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "prefers Retry-After over X-RateLimit-Reset" do
date = DateTime.utc_now() |> DateTime.add(1200) |> DateTime.to_iso8601()
mock_response(429, [{"x-ratelimit-reset", date}, {"retry-after", "600"}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
test "falls back to the next header when one is invalid" do
date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
mock_response(429, [{"retry-after", "soon"}, {"x-ratelimit-reset", date}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(600)
end
for value <- ["soon", "-5", "0", "12.5", "", "Wed, 21 Oct 2015 07:28:00 GMT"] do
test "ignores the invalid or past Retry-After value #{inspect(value)}" do
mock_response(429, [{"retry-after", unquote(value)}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(300)
end
end
test "caps absurd values at one hour" do
mock_response(429, [{"retry-after", "99999999999"}])
assert {:error, :ratelimit} = Backoff.get(@url)
assert_ttl_about(3600)
end
end
end

View file

@ -0,0 +1,13 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.DefaultAdapterTest do
use ExUnit.Case, async: true
# The test environment uses Tesla.Mock, and config.exs imports the config
# of the current Mix env, so check the shipped default in the file itself.
test "Gun is the default HTTP client" do
assert File.read!("config/config.exs") =~ ~r/^config :tesla, adapter: Tesla.Adapter.Gun$/m
end
end

View file

@ -0,0 +1,199 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Regression guard for the Hackney dependency, not for Pleroma code.
#
# Hackney 4 leaks a `hackney_conn` gen_statem for an unpooled request that ends
# without a reusable socket: a body cut short by the peer, a bodyless 204/304,
# or a failed body read. `read_full_body/2` hands back `socket = undefined`, so
# the connection goes straight to its `closed` state, bypassing the reuse check;
# `closed(enter)` arms a stop timer only for pooled connections, and an unpooled
# one is owned by `hackney_conn_sup`, so the owner-DOWN cleanup never fires
# either. The process parks forever holding every refc binary it read.
#
# Pleroma cannot work around this: since 4.0 Hackney ignores `with_body` and
# always returns a fully read binary, so a synchronous request never yields a
# handle to close. Up to 4.8.2 a truncated read also reported `{:ok, body}`;
# since 4.8.3 it is `{:error, {:closed, partial_body}}`.
#
# Media proxy warming hits this whenever an attachment fetch is cut short and
# the outbound proxy is used as a CONNECT tunnel, since tunnels are never
# pooled. That is how it was found.
#
# Fixed upstream by https://github.com/benoitc/hackney/pull/919. Should this
# start failing, mix.exs has moved to a Hackney version without the fix.
#
# https://github.com/benoitc/hackney/issues/918
defmodule Pleroma.HTTP.HackneyConnLeakTest do
use ExUnit.Case, async: false
alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper
@sent_bytes 64 * 1024
@announced_bytes 100_000_000
setup do
{:ok, _} = Application.ensure_all_started(:hackney)
{:ok, server} = start_server()
on_exit(fn -> stop_server(server) end)
{:ok, server: server}
end
test "an unpooled request with a truncated body does not leak a connection process", %{
server: server
} do
before = conn_pids()
# pool: false takes hackney's connect_direct/4 path, the same unpooled shape
# a CONNECT tunnel gets. A pooled connection is reaped by the pool's own
# grace timer and would not show the leak.
assert {:error, {:closed, body}} = request(server, pool: false)
# The short read hands back what did arrive.
assert byte_size(body) == @sent_bytes
leaked = wait_for_cleanup(before)
assert leaked == [],
"leaked #{length(leaked)} hackney_conn process(es): " <>
inspect(Enum.map(leaked, &describe/1))
end
# A control rather than a guard: a pooled connection is stopped by the pool's
# own grace timer both before and after the fix. It is here to show the
# unpooled assertion above is measuring the right thing.
test "a pooled request with a truncated body does not leak either", %{server: server} do
before = conn_pids()
assert {:error, {:closed, _}} = request(server, pool: :media)
assert wait_for_cleanup(before) == []
end
defp request(server, extra_opts) do
url = "#{server.base_url}/truncated"
adapter_opts =
HackneyAdapterHelper.options([proxy: false] ++ extra_opts, URI.parse(url))
client = Tesla.client([], Tesla.Adapter.Hackney)
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
end
defp conn_pids do
:hackney_conn_sup
|> Process.whereis()
|> case do
nil ->
[]
_ ->
for {_, pid, _, _} <- :supervisor.which_children(:hackney_conn_sup), is_pid(pid), do: pid
end
|> MapSet.new()
end
# Only connections this test created matter; the pool may add unrelated ones.
# A pooled connection gets a brief grace window before it stops, so poll
# rather than sampling once.
defp wait_for_cleanup(before, attempts \\ 40) do
leaked =
conn_pids()
|> Enum.reject(&MapSet.member?(before, &1))
|> Enum.filter(&Process.alive?/1)
cond do
leaked == [] -> []
attempts == 0 -> leaked
true -> Process.sleep(50) && wait_for_cleanup(before, attempts - 1)
end
end
defp describe(pid) do
state =
try do
pid |> :sys.get_state(200) |> elem(0)
rescue
_ -> :unknown
catch
:exit, _ -> :dead
end
binary =
case Process.info(pid, :binary) do
{:binary, refs} -> Enum.reduce(refs, 0, fn ref, acc -> acc + elem(ref, 1) end)
_ -> 0
end
%{pid: pid, state: state, binary_bytes: binary}
end
# Announces a body far larger than it sends, then closes: what an upstream
# aborting mid-stream looks like to the client.
defp start_server do
{:ok, listener} =
:gen_tcp.listen(0, [
:binary,
active: false,
packet: :raw,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
{:ok, acceptor} = Task.start(fn -> accept_loop(listener) end)
{:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}}
end
# Closing the listener makes the blocked accept/1 return {:error, :closed},
# which ends the loop.
defp stop_server(%{listener: listener, acceptor: acceptor}) do
:ok = :gen_tcp.close(listener)
if Process.alive?(acceptor), do: Process.exit(acceptor, :kill)
end
defp accept_loop(listener) do
case :gen_tcp.accept(listener) do
{:ok, socket} ->
serve(socket)
accept_loop(listener)
{:error, _} ->
:ok
end
end
defp serve(socket) do
with {:ok, _data} <- recv_headers(socket) do
:gen_tcp.send(socket, [
"HTTP/1.1 200 OK\r\n",
"Content-Type: application/octet-stream\r\n",
"Content-Length: #{@announced_bytes}\r\n\r\n",
:binary.copy("x", @sent_bytes)
])
end
:gen_tcp.close(socket)
end
defp recv_headers(socket, acc \\ <<>>) do
case :gen_tcp.recv(socket, 0, 1_000) do
{:ok, data} ->
acc = acc <> data
cond do
:binary.match(acc, "\r\n\r\n") != :nomatch -> {:ok, acc}
byte_size(acc) > 8_192 -> {:error, :too_large}
true -> recv_headers(socket, acc)
end
{:error, _} = error ->
error
end
end
end

View file

@ -19,30 +19,6 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
{:ok, tls_server: tls_server, proxy: proxy}
end
test "hackney follow_redirect crashes behind CONNECT proxy on relative redirects", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/redirect"
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true,
force_redirect: true
]
{pid, ref} = spawn_monitor(fn -> :hackney.request(:get, url, [], <<>>, opts) end)
assert_receive {:DOWN, ^ref, :process, ^pid, reason}, 5_000
assert match?({%FunctionClauseError{}, _}, reason) or match?(%FunctionClauseError{}, reason) or
match?({:function_clause, _}, reason)
end
test "redirects work via proxy when hackney follow_redirect is disabled", %{
tls_server: tls_server,
proxy: proxy
@ -52,7 +28,7 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
adapter_opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: false,
@ -75,7 +51,7 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
@ -97,7 +73,7 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
@ -119,7 +95,7 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
@ -131,6 +107,133 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
Pleroma.ReverseProxy.Client.Hackney.close(ref)
end
test "hackney reverse proxy cancels timed out asynchronous requests", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/delayed"
opts = [
pool: :media,
proxy: proxy.proxy_url,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 10,
follow_redirect: false
]
assert {:error, :timeout} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
refute_receive {:hackney_response, _, _}, 150
end
test "hackney reverse proxy normalizes asynchronous connection errors", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/closed"
opts = [
pool: :media,
proxy: proxy.proxy_url,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: false
]
assert {:error, :closed} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
end
# A pooled connection serving a bodyless response is checked back in by hackney
# at header time, so stopping that ref afterwards would evict a connection
# another request may already hold. These go direct rather than through the
# CONNECT proxy, because a proxy tunnel is never pooled.
test "hackney reverse proxy marks a pooled bodyless response as already released",
%{tls_server: tls_server} do
url = "#{tls_server.base_url}/no_content"
assert {:ok, 204, _headers, client} =
Pleroma.ReverseProxy.Client.Hackney.request(
:get,
url,
[],
"",
direct_opts(tls_server)
)
assert %{released: true} = client
assert :done = Pleroma.ReverseProxy.Client.Hackney.stream_body(client)
assert :ok = Pleroma.ReverseProxy.Client.Hackney.close(client)
end
test "hackney reverse proxy still owns a response that carries a body",
%{tls_server: tls_server} do
url = "#{tls_server.base_url}/final"
assert {:ok, 200, _headers, client} =
Pleroma.ReverseProxy.Client.Hackney.request(
:get,
url,
[],
"",
direct_opts(tls_server)
)
refute Map.has_key?(client, :released)
assert "ok" == collect_body(client)
# Streaming to the end must stop the conn: hackney only parks it, and this
# one has no idle timeout and no live owner monitor to reap it.
refute Process.alive?(client.ref)
end
# An unpooled connection is never checked in and parks forever with no idle
# timeout, so it stays ours to stop even when the response has no body.
test "hackney reverse proxy still owns a bodyless response over a proxy tunnel", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/no_content"
opts = Keyword.put(direct_opts(tls_server), :proxy, proxy.proxy_url)
assert {:ok, 204, _headers, client} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
refute Map.has_key?(client, :released)
Pleroma.ReverseProxy.Client.Hackney.close(client)
end
test "hackney reverse proxy still owns a bodyless response when pooling is off", %{
tls_server: tls_server
} do
url = "#{tls_server.base_url}/no_content"
opts = Keyword.put(direct_opts(tls_server), :pool, false)
assert {:ok, 204, _headers, client} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
refute Map.has_key?(client, :released)
Pleroma.ReverseProxy.Client.Hackney.close(client)
end
# proxy: false so a proxy in the build environment cannot turn these into
# tunnelled requests, which are never pooled and would defeat the point.
defp direct_opts(tls_server) do
[
pool: :media,
proxy: false,
ssl_options: tls_client_options(tls_server),
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: false
]
end
defp collect_body(ref, acc \\ "") do
case Pleroma.ReverseProxy.Client.Hackney.stream_body(ref) do
:done -> acc
@ -161,7 +264,13 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
accept_tls_loop(listener)
end)
{:ok, %{listener: listener, acceptor: acceptor, base_url: "https://127.0.0.1:#{port}"}}
{:ok,
%{
listener: listener,
acceptor: acceptor,
certfile: certfile,
base_url: "https://127.0.0.1:#{port}"
}}
end
defp stop_tls_redirect_server(%{listener: listener, acceptor: acceptor}) do
@ -191,6 +300,16 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
{:ok, data} <- recv_ssl_headers(ssl_socket),
{:ok, path} <- parse_path(data) do
case path do
"/closed" ->
:ok
"/no_content" ->
send_ssl_response(ssl_socket, 204, "No Content", [], "")
"/delayed" ->
Process.sleep(100)
send_ssl_response(ssl_socket, 200, "OK", [], "ok")
"/infinite_redirect" ->
send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/infinite_redirect"}], "")
@ -210,11 +329,17 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
:ssl.close(ssl_socket)
else
_ ->
_ = :gen_tcp.close(tcp_socket)
_ = :ssl.close(tcp_socket)
:ok
end
end
defp tls_client_options(_tls_server) do
[server_name_indication: ~c"localhost", verify_fun: {&accept_test_certificate/3, []}]
end
defp accept_test_certificate(_cert, _event, state), do: {:valid, state}
defp recv_ssl_headers(socket, acc \\ <<>>) do
case :ssl.recv(socket, 0, 1_000) do
{:ok, data} ->

View file

@ -0,0 +1,163 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Hackney 4 ignores `with_body` and always returns a fully read binary, so
# `Tesla.Adapter.Hackney` never reaches the clause that honours `:max_body`.
# Hackney 4.9 enforces `max_body` itself while reading (CVE-2026-97200);
# Pleroma.Tesla.Middleware.MaxBody stays as the check for every adapter. These
# exercise both against the real adapter.
defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do
use ExUnit.Case, async: false
alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper
@body_bytes 64 * 1024
setup do
{:ok, _} = Application.ensure_all_started(:hackney)
{:ok, server} = start_server()
on_exit(fn -> stop_server(server) end)
{:ok, server: server}
end
defp request(url, extra_opts) do
adapter_opts =
HackneyAdapterHelper.options(
[pool: :media, proxy: false] ++ extra_opts,
URI.parse(url)
)
client =
Tesla.client(
[Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.MaxBody],
Tesla.Adapter.Hackney
)
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
end
test "a body over max_body is refused", %{server: server} do
assert {:error, :body_too_large} =
request("#{server.base_url}/big", max_body: 1024)
end
# Without a Content-Length the header pre-check cannot fire, so only a size
# check on the body can refuse this.
test "a chunked body over max_body is refused on size alone", %{server: server} do
assert {:error, :body_too_large} =
request("#{server.base_url}/chunked", max_body: 1024)
end
test "a body under max_body is returned", %{server: server} do
assert {:ok, %Tesla.Env{status: 200, body: body}} =
request("#{server.base_url}/big", max_body: @body_bytes * 2)
assert byte_size(body) == @body_bytes
end
test "without max_body the body is returned", %{server: server} do
assert {:ok, %Tesla.Env{status: 200, body: body}} = request("#{server.base_url}/big", [])
assert byte_size(body) == @body_bytes
end
# Hackney 4.0 to 4.8 read the whole body whatever `max_body` said; 4.9
# stops reading once the body goes over it (CVE-2026-97200). Should this
# start failing, the hackney pin has gone back to a version without the fix.
test "the bare adapter enforces max_body", %{server: server} do
url = "#{server.base_url}/chunked"
adapter_opts =
HackneyAdapterHelper.options(
[pool: :media, proxy: false, max_body: 1024],
URI.parse(url)
)
client = Tesla.client([], Tesla.Adapter.Hackney)
assert {:error, :body_too_large} =
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
end
defp start_server do
{:ok, listener} =
:gen_tcp.listen(0, [
:binary,
active: false,
packet: :raw,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
{:ok, acceptor} = Task.start(fn -> accept_loop(listener) end)
{:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}}
end
# Closing the listener makes the blocked accept/1 return {:error, :closed},
# which ends the loop.
defp stop_server(%{listener: listener, acceptor: acceptor}) do
:ok = :gen_tcp.close(listener)
if Process.alive?(acceptor), do: Process.exit(acceptor, :kill)
end
defp accept_loop(listener) do
case :gen_tcp.accept(listener) do
{:ok, socket} ->
serve(socket)
accept_loop(listener)
{:error, _} ->
:ok
end
end
defp serve(socket) do
with {:ok, data} <- recv_headers(socket) do
body = :binary.copy("x", @body_bytes)
if String.contains?(data, "/chunked") do
# No Content-Length, so only the body-size check can catch this.
:gen_tcp.send(socket, [
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/plain\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: close\r\n\r\n",
:erlang.integer_to_list(byte_size(body), 16),
"\r\n",
body,
"\r\n0\r\n\r\n"
])
else
:gen_tcp.send(socket, [
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/plain\r\n",
"Content-Length: #{byte_size(body)}\r\n",
"Connection: close\r\n\r\n",
body
])
end
end
:gen_tcp.close(socket)
end
defp recv_headers(socket, acc \\ <<>>) do
case :gen_tcp.recv(socket, 0, 1_000) do
{:ok, data} ->
acc = acc <> data
cond do
:binary.match(acc, "\r\n\r\n") != :nomatch -> {:ok, acc}
byte_size(acc) > 8_192 -> {:error, :too_large}
true -> recv_headers(socket, acc)
end
{:error, _} = error ->
error
end
end
end

View file

@ -22,7 +22,7 @@ defmodule Pleroma.HTTP.HackneyRedirectRegressionTest do
adapter_opts =
HackneyAdapterHelper.options(
[pool: :media, follow_redirect: false, no_proxy_env: true],
[pool: :media, follow_redirect: false, proxy: false],
uri
)

View file

@ -0,0 +1,70 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.TLSTest do
use ExUnit.Case, async: true
alias Pleroma.HTTP.TLS
describe "ssl_options/1" do
test "verifies peers against the CAStore bundle" do
opts = TLS.ssl_options()
assert opts[:verify] == :verify_peer
assert opts[:cacertfile] == CAStore.file_path()
assert [match_fun: match_fun] = opts[:customize_hostname_check]
assert is_function(match_fun, 2)
end
test "does not accept certificates from the peer chain as trust anchors" do
partial_chain = TLS.ssl_options()[:partial_chain]
assert partial_chain.([:certificate]) == :unknown_ca
end
test "keeps CA certificates that the admin set" do
opts = TLS.ssl_options(cacerts: [:private_ca])
assert opts[:cacerts] == [:private_ca]
refute Keyword.has_key?(opts, :cacertfile)
end
test "keeps the CA bundle when other options are given" do
opts = TLS.ssl_options(versions: [:"tlsv1.2"])
assert opts[:versions] == [:"tlsv1.2"]
assert opts[:cacertfile] == CAStore.file_path()
assert opts[:verify] == :verify_peer
end
end
describe "put_hackney_ssl_options/1" do
test "adds the TLS options" do
opts = TLS.put_hackney_ssl_options(pool: :media)
assert opts[:pool] == :media
assert opts[:ssl_options] == TLS.ssl_options()
assert opts[:proxy_ssl_options] == TLS.ssl_options()
end
test "merges given ssl_options into the TLS options" do
opts = TLS.put_hackney_ssl_options(ssl_options: [versions: [:"tlsv1.2"]])
assert opts[:ssl_options] == TLS.ssl_options(versions: [:"tlsv1.2"])
end
test "turns insecure into an explicit verify_none" do
opts = TLS.put_hackney_ssl_options(insecure: true)
assert opts[:ssl_options] == [verify: :verify_none]
refute Keyword.has_key?(opts, :proxy_ssl_options)
end
test "secures the connection to an HTTPS proxy" do
opts = TLS.put_hackney_ssl_options(proxy_ssl_options: [versions: [:"tlsv1.3"]])
assert opts[:proxy_ssl_options] == TLS.ssl_options(versions: [:"tlsv1.3"])
end
end
end

View file

@ -29,6 +29,10 @@ defmodule Pleroma.HTTPTest do
%{method: :post, url: "http://example.com/world"} ->
%Tesla.Env{status: 200, body: "world"}
# Hackney 4.9 hands back the bytes it read before the peer closed.
%{method: :get, url: "http://example.com/truncated"} ->
{:error, {:closed, "partial body"}}
%{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"} ->
%Tesla.Env{status: 200, body: "emoji data"}
@ -81,6 +85,12 @@ defmodule Pleroma.HTTPTest do
end
end
describe "get/1 with a truncated body" do
test "drops the partial body from the error" do
assert HTTP.get("http://example.com/truncated") == {:error, :closed}
end
end
describe "get/2 (with headers)" do
test "returns successfully result for json content-type" do
assert HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) ==

View file

@ -7,8 +7,11 @@ defmodule Pleroma.NotificationTest do
import Pleroma.Factory
require Pleroma.Constants
alias Pleroma.FollowingRelationship
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
@ -276,6 +279,22 @@ defmodule Pleroma.NotificationTest do
assert {:ok, []} == Notification.create_notifications(status)
end
test "it creates notifications for objects without content when the user has filters" do
author = insert(:user)
user = insert(:user)
insert(:filter, user: user, phrase: "cofe", hide: true)
{:ok, status} = CommonAPI.post(author, %{status: "hey @#{user.nickname}"})
object = Object.normalize(status, fetch: false)
{:ok, _} =
object
|> Object.change(%{data: Map.delete(object.data, "content")})
|> Object.update_and_set_cache()
assert {:ok, [_]} = Notification.create_notifications(Pleroma.Activity.get_by_id(status.id))
end
test "it creates notifications if content matches with a not irreversible filter" do
user = insert(:user)
subscriber = insert(:user)
@ -611,6 +630,102 @@ defmodule Pleroma.NotificationTest do
assert other_user in enabled_receivers
end
defp incoming_note_tagging(author, to, tagged_ap_ids) do
tags = Enum.map(tagged_ap_ids, &%{"type" => "Mention", "href" => &1, "name" => &1})
{:ok, activity} =
Transmogrifier.handle_incoming(%{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"to" => to,
"actor" => author.ap_id,
"object" => %{
"type" => "Note",
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"to" => to,
"content" => "tagged",
"tag" => tags,
"attributedTo" => author.ap_id
}
})
activity
end
defp notified_ap_ids(activity),
do: activity |> Notification.get_notified_from_activity() |> Enum.map(& &1.ap_id)
test "it does not notify tagged users who cannot see a followers-only post" do
author = insert(:user)
tagged = insert(:user)
activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id])
refute tagged.ap_id in notified_ap_ids(activity)
end
test "it notifies tagged followers of a followers-only post" do
author = insert(:user)
tagged = insert(:user)
{:ok, _, _} = Pleroma.User.follow(tagged, author)
activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id])
assert tagged.ap_id in notified_ap_ids(activity)
end
test "it does not notify tagged users who are not addressed by a direct message" do
author = insert(:user)
addressed = insert(:user)
tagged = insert(:user)
activity = incoming_note_tagging(author, [addressed.ap_id], [tagged.ap_id])
receivers = notified_ap_ids(activity)
assert addressed.ap_id in receivers
refute tagged.ap_id in receivers
end
test "it notifies tagged users of a public post even if they are not addressed" do
author = insert(:user)
tagged = insert(:user)
activity =
incoming_note_tagging(author, [Pleroma.Constants.as_public()], [tagged.ap_id])
assert tagged.ap_id in notified_ap_ids(activity)
end
test "it does not notify tagged users with a pending follow request" do
author = insert(:user, is_locked: true)
tagged = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(author, tagged)
activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id])
refute tagged.ap_id in notified_ap_ids(activity)
end
test "it ignores tags of unknown users in a followers-only post" do
author = insert(:user)
tagged = insert(:user)
follower = insert(:user)
{:ok, _, _} = Pleroma.User.follow(follower, author)
activity =
incoming_note_tagging(author, [author.follower_address], [
"https://unknown.example/users/nobody",
tagged.ap_id,
follower.ap_id
])
receivers = notified_ap_ids(activity)
refute tagged.ap_id in receivers
assert follower.ap_id in receivers
end
test "it does not send notifications to users who are only cc in new messages" do
user = insert(:user)
other_user = insert(:user)

View file

@ -127,4 +127,56 @@ defmodule Pleroma.Object.ContainmentTest do
:ok = Containment.contain_child(data)
end
end
describe "get_actor/1" do
test "returns nil for actor shapes without an id" do
for actor <- [%{"type" => "Person"}, %{"id" => 1}, 1, true] do
assert nil == Containment.get_actor(%{"actor" => actor})
assert nil == Containment.get_actor(%{"actor" => nil, "attributedTo" => actor})
end
assert nil == Containment.get_actor(%{})
end
test "contain_origin/2 rejects actor shapes without an id" do
assert :error ==
Containment.contain_origin("https://a.test/o/1", %{
"attributedTo" => %{"type" => "Person"}
})
end
end
describe "contain_id_to_fetch/2" do
test "matches the exact id" do
assert :ok =
Containment.contain_id_to_fetch("https://a.test/o/1", %{
"id" => "https://a.test/o/1"
})
end
test "ignores fragments, a trailing slash and the host case" do
assert :ok =
Containment.contain_id_to_fetch("https://A.test/o/1/", %{
"id" => "https://a.test/o/1#x"
})
end
test "rejects another id on the same host" do
assert :error =
Containment.contain_id_to_fetch("https://a.test/o/1", %{
"id" => "https://a.test/o/2"
})
end
test "rejects a different query" do
assert :error =
Containment.contain_id_to_fetch("https://a.test/o?page=1", %{
"id" => "https://a.test/o?page=2"
})
end
test "rejects documents without an id" do
assert :error = Containment.contain_id_to_fetch("https://a.test/o/1", %{})
end
end
end

View file

@ -48,6 +48,8 @@ defmodule Pleroma.Object.FetcherTest do
%{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} ->
%Tesla.Env{
status: 200,
# Pleroma redirects /notice/:id to the object id
url: "https://social.sakamoto.gq/objects/f20f2497-66d9-4a52-a2e1-1be2a39c32c1",
body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"),
headers: HttpRequestMock.activitypub_object_headers()
}
@ -92,6 +94,27 @@ defmodule Pleroma.Object.FetcherTest do
end
end
describe "rate limiting" do
test "a backed-off host gets no further requests" do
test_pid = self()
mock(fn %{method: :get, url: url} ->
send(test_pid, {:request, url})
%Tesla.Env{status: 429, headers: [{"retry-after", "600"}]}
end)
first = "https://ratelimited.example.com/objects/1"
second = "https://ratelimited.example.com/objects/2"
assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(first)
assert_received {:request, ^first}
assert {:error, :ratelimit} = Fetcher.fetch_and_contain_remote_object_from_id(second)
assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(second)
refute_received {:request, _}
end
end
describe "max thread distance restriction" do
@ap_id "http://mastodon.example.org/@admin/99541947525187367"
setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
@ -463,6 +486,78 @@ defmodule Pleroma.Object.FetcherTest do
%{object1: local_object1, object2: local_object2}
end
test "it rejects a refetched object whose actor changed", %{object1: object1} do
insert(:user, ap_id: "https://mastodon.social/users/someone_else")
{:ok, o} = Object.create(object1)
mock(fn %{method: :get, url: "https://mastodon.social/1"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
object1
|> Map.merge(%{
"actor" => "https://mastodon.social/users/someone_else",
"attributedTo" => "https://mastodon.social/users/someone_else",
"content" => "hijacked"
})
|> Jason.encode!()
}
end)
assert {:error, _} = Fetcher.refetch_object(o)
assert Object.get_by_ap_id("https://mastodon.social/1").data["content"] == "test 1"
end
test "it rejects a refetched object whose author has no id", %{object1: object1} do
{:ok, o} = Object.create(object1)
mock(fn %{method: :get, url: "https://mastodon.social/1"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
object1
|> Map.delete("actor")
|> Map.put("attributedTo", %{"type" => "Person"})
|> Jason.encode!()
}
end)
assert {:error, _} = Fetcher.refetch_object(o)
end
test "it rejects a refetched object whose id changed", %{object1: object1} do
{:ok, o} = Object.create(object1)
mock(fn %{method: :get, url: "https://mastodon.social/1"} ->
%Tesla.Env{
status: 200,
url: "https://mastodon.social/2",
headers: [{"content-type", "application/activity+json"}],
body: object1 |> Map.put("id", "https://mastodon.social/2") |> Jason.encode!()
}
end)
assert {:error, _} = Fetcher.refetch_object(o)
end
test "it rejects a refetched object whose id differs only by a trailing slash", %{
object1: object1
} do
{:ok, o} = Object.create(object1)
mock(fn %{method: :get, url: "https://mastodon.social/1"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body: object1 |> Map.put("id", "https://mastodon.social/1/") |> Jason.encode!()
}
end)
assert {:error, _} = Fetcher.refetch_object(o)
end
test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do
full_object1 =
object1
@ -642,7 +737,23 @@ defmodule Pleroma.Object.FetcherTest do
})
}
# No redirect - same domain
# Same-domain redirect to the object id
%{method: :get, url: "https://original.test/@actor/redirect"} ->
%Tesla.Env{
status: 200,
url: "https://original.test/objects/redirect-target",
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => "https://original.test/objects/redirect-target",
"type" => "Note",
"content" => "This has a same-domain redirect",
"actor" => "https://original.test/users/actor",
"attributedTo" => "https://original.test/users/actor"
})
}
# Same-domain redirect to a document claiming the original URL as id
%{method: :get, url: "https://original.test/objects/same-domain-redirect"} ->
%Tesla.Env{
status: 200,
@ -695,12 +806,19 @@ defmodule Pleroma.Object.FetcherTest do
test "it accepts objects with same-domain redirects" do
assert {:ok, data} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/same-domain-redirect"
"https://original.test/@actor/redirect"
)
assert data["content"] == "This has a same-domain redirect"
end
test "it rejects redirect targets that claim the original URL as id" do
assert {:error, _} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/same-domain-redirect"
)
end
test "it handles responses without URL field (common in tests)" do
assert {:ok, data} =
Fetcher.fetch_and_contain_remote_object_from_id(
@ -711,6 +829,150 @@ defmodule Pleroma.Object.FetcherTest do
end
end
describe "id containment" do
setup do
note = fn id, content ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => id,
"type" => "Note",
"content" => content,
"actor" => "https://host.test/users/actor",
"attributedTo" => "https://host.test/users/actor"
})
}
end
mock(fn
# display URL serving the canonical object
%{method: :get, url: "https://host.test/@actor/1"} ->
note.("https://host.test/objects/1", "display")
%{method: :get, url: "https://host.test/objects/1"} ->
note.("https://host.test/objects/1", "canonical")
# uploaded file claiming to be another object on the same host
%{method: :get, url: "https://host.test/media/evil.json"} ->
note.("https://host.test/objects/2", "spoofed")
%{method: :get, url: "https://host.test/objects/2"} ->
note.("https://host.test/objects/2", "real")
# canonical id does not serve itself
%{method: :get, url: "https://host.test/media/loop.json"} ->
note.("https://host.test/objects/3", "loop")
%{method: :get, url: "https://host.test/objects/3"} ->
note.("https://host.test/objects/4", "other")
# canonical id redirects
%{method: :get, url: "https://host.test/media/redirect.json"} ->
note.("https://host.test/objects/5", "redirect")
%{method: :get, url: "https://host.test/objects/5"} ->
%{note.("https://host.test/objects/5", "moved") | url: "https://host.test/media/x"}
%{method: :get, url: "https://host.test/objects/slash"} ->
note.("https://host.test/objects/slash/", "slash")
%{method: :get, url: "https://host.test/objects/no-id"} ->
note.(nil, "no id")
end)
:ok
end
test "it refetches display URLs from the canonical id" do
assert {:ok, %{"id" => "https://host.test/objects/1", "content" => "canonical"}} =
Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/@actor/1")
end
test "it does not trust a document claiming another id on the same host" do
assert {:ok, %{"id" => "https://host.test/objects/2", "content" => "real"}} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://host.test/media/evil.json"
)
end
test "it refetches at most once" do
assert {:error, :id_mismatch} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://host.test/media/loop.json"
)
end
test "it does not follow redirects when refetching from the canonical id" do
assert {:error, _} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://host.test/media/redirect.json"
)
end
test "it ignores a trailing slash" do
assert {:ok, %{"content" => "slash"}} =
Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/objects/slash")
end
test "it accepts ids that only differ by URL encoding" do
clear_config(:test_url_encoding, true)
mock(fn %{method: :get, url: url} ->
# the encoder sends uppercase escapes
assert url in [
"https://host.test/users/%E3%81%82",
"https://host.test/notes/%E3%81%82"
]
id =
if url =~ "users",
do: "https://host.test/users/あ",
else: "https://host.test/notes/%e3%81%82"
%Tesla.Env{
status: 200,
url: url,
headers: [{"content-type", "application/activity+json"}],
body: Jason.encode!(%{"id" => id, "type" => "Note"})
}
end)
assert {:ok, _} =
Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/users/あ")
assert {:ok, _} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://host.test/notes/%e3%81%82"
)
end
test "it rejects objects whose author has no id" do
mock(fn %{method: :get, url: "https://host.test/objects/anon" = url} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => url,
"type" => "Note",
"content" => "anon",
"attributedTo" => %{"type" => "Person", "name" => "nobody"}
})
}
end)
assert {:containment, :error} =
Fetcher.fetch_object_from_id("https://host.test/objects/anon")
end
test "it rejects documents without an id" do
assert {:error, _} =
Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/objects/no-id")
end
end
describe "fetch with history" do
setup do
object2 = %{

View file

@ -0,0 +1,33 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Repo.Migrations.BackfillInstancesFromUsersTest do
use Pleroma.DataCase
import Pleroma.Factory
import Pleroma.Tests.Helpers
alias Pleroma.Instances.Instance
setup_all do: require_migration("20261028000000_backfill_instances_from_users")
test "up/0 adds the hosts of remote users and keeps existing records", %{migration: migration} do
unreachable_since = ~N[2020-01-01 00:00:00.000000]
insert(:instance, host: "known.example", unreachable_since: unreachable_since)
insert(:user, local: false, ap_id: "https://known.example/users/a")
insert(:user, local: false, ap_id: "https://new.example/users/a")
insert(:user, local: false, ap_id: "https://new.example/users/b")
insert(:user, local: false, ap_id: "https://ported.example:8443/users/c")
insert(:user, local: false, ap_id: "https://user@userinfo.example/users/d")
insert(:user, local: false, ap_id: "https://[2001:db8::1]/users/e")
insert(:user, local: true)
migration.up()
hosts = Instance |> Repo.all() |> Enum.map(& &1.host) |> Enum.sort()
assert hosts == ["known.example", "new.example", "ported.example", "userinfo.example"]
assert %{unreachable_since: ^unreachable_since} = Repo.get_by(Instance, host: "known.example")
end
end

View file

@ -0,0 +1,73 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Repo.Migrations.UploadFilterExiftoolToExifTest do
use Pleroma.DataCase
import Pleroma.Factory
import Pleroma.Tests.Helpers
alias Pleroma.ConfigDB
setup_all do: require_migration("20261027000000_upload_filter_exiftool_to_exif")
defp filters do
ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}).value[:filters]
end
test "up/0 renames the Exiftool filters", %{migration: migration} do
insert(:config,
group: :pleroma,
key: Pleroma.Upload,
value: [
filters: [
Pleroma.Upload.Filter.Dedupe,
Pleroma.Upload.Filter.Exiftool,
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
]
]
)
migration.up()
assert filters() == [
Pleroma.Upload.Filter.Dedupe,
Pleroma.Upload.Filter.Exif.StripLocation,
Pleroma.Upload.Filter.Exif.ReadDescription
]
end
test "down/0 restores the Exiftool filters", %{migration: migration} do
insert(:config,
group: :pleroma,
key: Pleroma.Upload,
value: [
filters: [
Pleroma.Upload.Filter.Exif.StripLocation,
Pleroma.Upload.Filter.Exif.ReadDescription
]
]
)
migration.down()
assert filters() == [
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
]
end
test "up/0 does nothing without a database config", %{migration: migration} do
assert migration.up() == nil
assert ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}) == nil
end
test "up/0 does not add filters to a database config without them", %{migration: migration} do
insert(:config, group: :pleroma, key: Pleroma.Upload, value: [uploader: Pleroma.Uploaders.S3])
migration.up()
value = ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}).value
assert value == [uploader: Pleroma.Uploaders.S3]
end
end

View file

@ -27,6 +27,21 @@ defmodule Pleroma.ReverseProxyTest do
setup :verify_on_exit!
# Every path that obtains a client ref releases it, so tests that do not assert
# on the release still need close/1 to be callable. Recording each call lets
# the tests below pin *exactly* once: a Mox `expect` would silently fall
# through to this stub on a second call.
setup do
test_pid = self()
stub(ClientMock, :close, fn client ->
send(test_pid, {:closed, client})
:ok
end)
:ok
end
defp start_bandit(url, opts \\ []) do
start_supervised!(
{Bandit,
@ -183,6 +198,100 @@ defmodule Pleroma.ReverseProxyTest do
assert conn.status == 200
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil}
end
test "uses max_read_duration as the default HTTP receive timeout", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/timeout", _, _, opts ->
assert opts[:recv_timeout] == 123
{:error, :timeout}
end)
capture_log(fn -> ReverseProxy.call(conn, "/timeout", max_read_duration: 123) end)
end
test "preserves an explicit HTTP receive timeout", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/timeout", _, _, opts ->
assert opts[:recv_timeout] == 456
{:error, :timeout}
end)
capture_log(fn ->
ReverseProxy.call(conn, "/timeout", max_read_duration: 123, http: [recv_timeout: 456])
end)
end
test "keeps unlimited read durations unlimited", %{conn: conn} do
for {max_read_duration, url} <- [{0, "/zero-timeout"}, {:infinity, "/infinite-timeout"}] do
ClientMock
|> expect(:request, fn :get, ^url, _, _, opts ->
assert opts[:recv_timeout] == :infinity
{:error, :timeout}
end)
capture_log(fn ->
ReverseProxy.call(conn, url, max_read_duration: max_read_duration)
end)
end
end
end
# ReverseProxy releases exactly what it abandons. A body it streamed to the end
# is the client's to release, mirroring how the gun client frees its stream on
# :done -- see Pleroma.ReverseProxy.Client.Hackney.stream_body/1 for why an
# abandoned Hackney ref never gets reaped on its own.
describe "client ref release" do
test "leaves a fully streamed body for the client to release", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/release", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}], %{url: "/release"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/release")
assert conn.halted
refute_received {:closed, _}
end
test "releases the client when content-length exceeds max_body_length", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/release-huge", _, _, _ ->
{:ok, 200, [{"content-length", "102227307"}], %{url: "/release-huge"}}
end)
assert capture_log(fn ->
ReverseProxy.call(conn, "/release-huge", max_body_length: 25 * 1_048_576)
end) =~ ":body_too_large"
assert_received {:closed, %{url: "/release-huge"}}
refute_received {:closed, _}
end
test "releases the client when the upstream status is not proxiable", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/release-418", _, _, _ ->
{:ok, 418, [], %{url: "/release-418"}}
end)
assert capture_log(fn -> ReverseProxy.call(conn, "/release-418") end) =~
"failed with HTTP status 418"
assert_received {:closed, %{url: "/release-418"}}
refute_received {:closed, _}
end
test "has nothing to release when the reply carries no ref", %{conn: conn} do
ClientMock
|> expect(:request, fn :head, "/release-headless", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}]}
end)
conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/release-headless")
assert conn.status == 200
refute_received {:closed, _}
end
end
test "use Pleroma's user agent in the request; don't pass the client's", %{conn: conn} do
@ -398,7 +507,7 @@ defmodule Pleroma.ReverseProxyTest do
refute :timeout in responses
end
defp stream_mock(invokes, with_close? \\ false) do
defp stream_mock(invokes) do
ClientMock
|> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
Registry.register(ClientMock, "/stream-bytes/" <> length, 0)
@ -424,10 +533,6 @@ defmodule Pleroma.ReverseProxyTest do
:done
end
end)
if with_close? do
expect(ClientMock, :close, fn _ -> :ok end)
end
end
describe "max_body" do
@ -458,7 +563,7 @@ defmodule Pleroma.ReverseProxyTest do
end
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
stream_mock(3, true)
stream_mock(3)
log =
capture_log(fn ->
@ -469,6 +574,9 @@ defmodule Pleroma.ReverseProxyTest do
assert log =~
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
assert_received {:closed, %{url: "/stream-bytes/50"}}
refute_received {:closed, _}
end
test "max_body_length accepts a body exactly at the limit", %{conn: conn} do

View file

@ -44,4 +44,31 @@ defmodule Pleroma.Search.DatabaseSearchTest do
assert result.id == other_post.id
end
describe "gin_fuzzy_search_limit" do
defp fuzzy_limit do
%{rows: [[value]]} = Pleroma.Repo.query!("SHOW gin_fuzzy_search_limit")
value
end
test "is not set on normal database connections" do
assert fuzzy_limit() == "0"
end
test "is only set for the search query when configured" do
clear_config([Search, :gin_fuzzy_search_limit], nil)
assert Search.with_fuzzy_limit(&fuzzy_limit/0) == "0"
clear_config([Search, :gin_fuzzy_search_limit], 10_000)
assert Search.with_fuzzy_limit(&fuzzy_limit/0) == "10000"
end
test "search still works with a limit" do
clear_config([Search, :gin_fuzzy_search_limit], 10_000)
{:ok, post} = CommonAPI.post(insert(:user), %{status: "it's wednesday my dudes"})
assert [%{id: id}] = Search.search(nil, "wednesday")
assert id == post.id
end
end
end

View file

@ -116,6 +116,135 @@ defmodule Pleroma.SignatureTest do
end
end
describe "check_signature_policy/2" do
@now ~U[2026-10-27 12:00:00Z]
defp policy_conn(method, signed_headers, extra_headers, extra_params \\ "") do
signature =
~s(keyId="https://remote.example/users/a#main-key",headers="#{signed_headers}") <>
extra_params <> ~s(,signature="fake")
%Plug.Conn{
method: method,
req_headers: [{"signature", signature}, {"host", "pleroma.example"} | extra_headers],
assigns: %{digest: "SHA-256=abc"}
}
end
defp date_header(seconds_ago) do
date = DateTime.add(@now, -seconds_ago) |> DateTime.to_naive()
{"date", Signature.signed_date(date)}
end
test "accepts a fresh signed GET (Pleroma, Mastodon, Misskey style)" do
for headers <- ["(request-target) host date", "(request-target) host date accept"] do
conn = policy_conn("GET", headers, [date_header(10)])
assert Signature.check_signature_policy(conn, @now) == :ok
end
end
test "accepts a fresh signed POST covering the digest" do
conn =
policy_conn("POST", "(request-target) host content-length digest date", [
date_header(10),
{"digest", "SHA-256=abc"}
])
assert Signature.check_signature_policy(conn, @now) == :ok
end
test "accepts (created) instead of date (GoToSocial style)" do
created = DateTime.to_unix(@now) - 10
conn =
policy_conn(
"POST",
"(request-target) (created) host digest",
[{"digest", "SHA-256=abc"}],
",algorithm=\"hs2019\",created=#{created}"
)
assert Signature.check_signature_policy(conn, @now) == :ok
end
test "rejects signatures that cover neither date nor (created)" do
conn = policy_conn("GET", "(request-target) host", [date_header(10)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_not_signed}
end
test "rejects a body request whose digest is not signed" do
conn =
policy_conn("POST", "(request-target) host date", [
date_header(10),
{"digest", "SHA-256=abc"}
])
assert Signature.check_signature_policy(conn, @now) == {:error, :digest_not_signed}
end
test "rejects a signed date that is missing or unparsable" do
conn = policy_conn("GET", "(request-target) host date", [])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
conn = policy_conn("GET", "(request-target) host date", [{"date", "yesterday"}])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
test "rejects more than one date header" do
conn =
policy_conn("GET", "(request-target) host date", [date_header(10), date_header(9000)])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
test "rejects a signed digest when the body was not hashed" do
conn =
policy_conn("POST", "(request-target) host date digest", [
date_header(10),
{"digest", "SHA-256=abc"}
])
|> Map.put(:assigns, %{})
assert Signature.check_signature_policy(conn, @now) == {:error, :body_not_hashed}
end
test "rejects a signed date that is too old or too far in the future" do
clear_config([:activitypub, :signature_max_age], 7200)
clear_config([:activitypub, :signature_max_clock_skew], 3600)
conn = policy_conn("GET", "(request-target) host date", [date_header(7200)])
assert Signature.check_signature_policy(conn, @now) == :ok
conn = policy_conn("GET", "(request-target) host date", [date_header(7201)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old}
conn = policy_conn("GET", "(request-target) host date", [date_header(-3600)])
assert Signature.check_signature_policy(conn, @now) == :ok
conn = policy_conn("GET", "(request-target) host date", [date_header(-3601)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_in_future}
end
test "rejects a (created) timestamp that is too old" do
clear_config([:activitypub, :signature_max_age], 60)
created = DateTime.to_unix(@now) - 61
conn =
policy_conn("GET", "(request-target) (created) host", [], ",created=#{created}")
assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old}
end
test "uses the cavage default of a signed date when headers are not given" do
conn = %Plug.Conn{
method: "GET",
req_headers: [{"signature", "keyId=\"https://remote.example/users/a#main-key\""}]
}
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
end
describe "key_id_to_actor_id/1" do
test "it properly deduces the actor id for misskey" do
assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") ==

View file

@ -0,0 +1,87 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Tesla.Middleware.MaxBodyTest do
use ExUnit.Case, async: true
alias Pleroma.Tesla.Middleware.MaxBody
defp client(adapter_opts, response) do
Tesla.client([{MaxBody, nil}], fn env ->
{:ok, %{env | status: 200, headers: response.headers, body: response.body}}
end)
|> then(fn c -> {c, [opts: [adapter: adapter_opts]]} end)
end
defp get(adapter_opts, response) do
{c, opts} = client(adapter_opts, response)
Tesla.get(c, "http://example.com/", opts)
end
test "passes a body within the limit through untouched" do
assert {:ok, %Tesla.Env{status: 200, body: "hello"}} =
get([max_body: 100], %{headers: [], body: "hello"})
end
test "rejects a body larger than the limit" do
assert {:error, :body_too_large} =
get([max_body: 4], %{headers: [], body: "hello"})
end
test "rejects when content-length exceeds the limit" do
assert {:error, :body_too_large} =
get([max_body: 4], %{headers: [{"content-length", "1000"}], body: ""})
end
test "allows a content-length equal to the limit" do
assert {:ok, %Tesla.Env{status: 200}} =
get([max_body: 5], %{headers: [{"content-length", "5"}], body: "hello"})
end
test "ignores an unparsable content-length" do
assert {:ok, %Tesla.Env{status: 200}} =
get([max_body: 100], %{headers: [{"content-length", "banana"}], body: "hi"})
end
test "does nothing without a max_body option" do
big = String.duplicate("x", 10_000)
assert {:ok, %Tesla.Env{body: ^big}} =
get([], %{headers: [{"content-length", "10000"}], body: big})
end
# body_as: :chunks hands back a client map, not a binary; the caller streams
# it and enforces its own limit (see Pleroma.Web.RichMedia.Helpers).
test "leaves a non-binary streaming body alone" do
body = %{pid: self(), stream: make_ref()}
assert {:ok, %Tesla.Env{body: ^body}} =
get([max_body: 1], %{headers: [], body: body})
end
# Rejecting a stream on the header alone would strand the connection lease:
# the env carrying the pid/stream is the caller's only handle to release it.
test "does not reject a streaming body on content-length alone" do
body = %{pid: self(), stream: make_ref()}
assert {:ok, %Tesla.Env{body: ^body}} =
get([max_body: 1], %{headers: [{"content-length", "20000000"}], body: body})
end
test "ignores content-length on a HEAD response" do
{c, opts} = client([max_body: 4], %{headers: [{"content-length", "9000000"}], body: ""})
assert {:ok, %Tesla.Env{status: 200}} = Tesla.head(c, "http://example.com/", opts)
end
test "ignores content-length on a bodyless status" do
client =
Tesla.client([{MaxBody, nil}], fn env ->
{:ok, %{env | status: 304, headers: [{"content-length", "9000000"}], body: ""}}
end)
assert {:ok, %Tesla.Env{status: 304}} =
Tesla.get(client, "http://example.com/", opts: [adapter: [max_body: 4]])
end
end

View file

@ -0,0 +1,85 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.Exif.ReadDescriptionTest do
use Pleroma.DataCase, async: true
alias Pleroma.Upload.Filter.Exif.ReadDescription
defp upload(fixture, attrs \\ []) do
path = Path.absname(Path.join("test/fixtures", fixture))
struct!(
%Pleroma.Upload{
name: fixture,
content_type: "image/" <> String.trim_leading(Path.extname(fixture), "."),
path: path,
tempfile: path,
description: nil
},
attrs
)
end
test "keeps description when not empty" do
upload =
upload("image_with_imagedescription_and_caption-abstract.jpg",
description: "Some description"
)
assert ReadDescription.filter(upload) == {:ok, :noop}
end
test "otherwise returns ImageDescription when present" do
upload = upload("image_with_imagedescription_and_caption-abstract.jpg")
assert ReadDescription.filter(upload) ==
{:ok, :filtered, %{upload | description: "a descriptive white pixel"}}
end
test "ignores stray data after the image" do
upload = upload("image_with_imagedescription_and_caption-abstract_and_stray_data_after.png")
assert {:ok, :filtered, %{description: "a descriptive white pixel"}} =
ReadDescription.filter(upload)
assert ReadDescription.filter(upload("image_with_stray_data_after.png")) == {:ok, :noop}
end
test "otherwise returns iptc:Caption-Abstract when present" do
upload = upload("image_with_caption-abstract.jpg")
assert ReadDescription.filter(upload) ==
{:ok, :filtered, %{upload | description: "an abstract white pixel"}}
end
test "reads ImageDescription from WebP and HEIC" do
assert {:ok, :filtered, %{description: "a webp with a location"}} =
ReadDescription.filter(upload("strip_location_gps.webp"))
assert {:ok, :filtered, %{description: "a heic with a location"}} =
ReadDescription.filter(upload("strip_location_gps.heic"))
end
test "does nothing when there is no description" do
assert ReadDescription.filter(upload("image_with_no_description.jpg")) == {:ok, :noop}
end
test "ignores descriptions that exceed the maximum length" do
clear_config([:instance, :description_limit], 5)
upload = upload("image_with_imagedescription_and_caption-abstract.jpg")
assert ReadDescription.filter(upload) == {:ok, :noop}
end
test "ignores content with only whitespace" do
upload = upload("image_with_imagedescription_and_caption-abstract_whitespaces.jpg")
assert ReadDescription.filter(upload) == {:ok, :noop}
end
test "does nothing when the file can't be read" do
upload = %{upload("image.jpg") | tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg")}
assert ReadDescription.filter(upload) == {:ok, :noop}
end
end

View file

@ -0,0 +1,72 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.Exif.StripLocationTest do
use Pleroma.DataCase, async: true
import Pleroma.Tests.ExifHelpers
alias Pleroma.Upload.Filter.Exif.StripLocation
for {fixture, content_type} <- [
{"DSCN0010.jpg", "image/jpeg"},
{"DSCN0010.png", "image/png"},
{"strip_location_gps.webp", "image/webp"},
{"strip_location_gps.heic", "image/heic"},
{"strip_location_gps_grid.heic", "image/heic"},
{"strip_location_gps.tiff", "image/tiff"}
] do
test "strips GPS data from #{fixture} (#{content_type})" do
upload = upload_copy(unquote(fixture), unquote(content_type))
assert gps_tags(upload.tempfile) =~ "GPS Latitude"
assert StripLocation.filter(upload) == {:ok, :filtered}
assert gps_tags(upload.tempfile) == ""
end
end
test "strips GPS data from XMP, including DJI drone positions" do
for fixture <- ["xmp_gps.jpg", "dji_gps.jpg"] do
upload = upload_copy(fixture, "image/jpeg")
assert exiftool_xmp(upload.tempfile) =~ "GPSLatitude"
assert StripLocation.filter(upload) == {:ok, :filtered}
refute exiftool_xmp(upload.tempfile) =~ ~r/GPS|AbsoluteAltitude/
end
end
defp exiftool_xmp(path) do
{output, 0} = System.cmd("exiftool", ["-m", "-s", "-xmp:all", path])
output
end
test "keeps other EXIF data in HEIC" do
upload = upload_copy("strip_location_gps.heic", "image/heic")
assert StripLocation.filter(upload) == {:ok, :filtered}
assert tag(upload.tempfile, "ImageDescription") == "a heic with a location"
assert tag(upload.tempfile, "ImageSize") == "64x48"
end
test "does nothing when there is no GPS data" do
upload = upload_copy("image.jpg", "image/jpeg")
original = File.read!(upload.tempfile)
assert StripLocation.filter(upload) == {:ok, :noop}
assert File.read!(upload.tempfile) == original
end
test "accepts PNG files with data after IEND" do
upload = upload_copy("image_with_stray_data_after.png", "image/png")
assert {:ok, _} = StripLocation.filter(upload)
end
test "ignores non-image uploads" do
upload = upload_copy("DSCN0010.jpg", "application/octet-stream")
assert StripLocation.filter(upload) == {:ok, :noop}
assert gps_tags(upload.tempfile) =~ "GPS Latitude"
end
end

View file

@ -1,144 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.Exiftool.ReadDescriptionTest do
use Pleroma.DataCase
alias Pleroma.Upload.Filter
@uploads %Pleroma.Upload{
name: "image_with_imagedescription_and_caption-abstract.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
tempfile: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
description: nil
}
test "keeps description when not empty" do
uploads = %Pleroma.Upload{
name: "image_with_imagedescription_and_caption-abstract.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
tempfile:
Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
description: "Some description"
}
assert Filter.Exiftool.ReadDescription.filter(uploads) ==
{:ok, :noop}
end
test "otherwise returns ImageDescription when present" do
uploads_after = %Pleroma.Upload{
name: "image_with_imagedescription_and_caption-abstract.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
tempfile:
Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"),
description: "a descriptive white pixel"
}
assert Filter.Exiftool.ReadDescription.filter(@uploads) ==
{:ok, :filtered, uploads_after}
end
test "Ignores warnings" do
uploads = %Pleroma.Upload{
name: "image_with_imagedescription_and_caption-abstract_and_stray_data_after.png",
content_type: "image/png",
path:
Path.absname(
"test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png"
),
tempfile:
Path.absname(
"test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png"
)
}
assert {:ok, :filtered, %{description: "a descriptive white pixel"}} =
Filter.Exiftool.ReadDescription.filter(uploads)
uploads = %Pleroma.Upload{
name: "image_with_stray_data_after.png",
content_type: "image/png",
path: Path.absname("test/fixtures/image_with_stray_data_after.png"),
tempfile: Path.absname("test/fixtures/image_with_stray_data_after.png")
}
assert {:ok, :filtered, %{description: nil}} = Filter.Exiftool.ReadDescription.filter(uploads)
end
test "otherwise returns iptc:Caption-Abstract when present" do
upload = %Pleroma.Upload{
name: "image_with_caption-abstract.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"),
tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"),
description: nil
}
upload_after = %Pleroma.Upload{
name: "image_with_caption-abstract.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"),
tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"),
description: "an abstract white pixel"
}
assert Filter.Exiftool.ReadDescription.filter(upload) ==
{:ok, :filtered, upload_after}
end
test "otherwise returns nil" do
uploads = %Pleroma.Upload{
name: "image_with_no_description.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image_with_no_description.jpg"),
tempfile: Path.absname("test/fixtures/image_with_no_description.jpg"),
description: nil
}
assert Filter.Exiftool.ReadDescription.filter(uploads) ==
{:ok, :filtered, uploads}
end
test "Return nil when image description from EXIF data exceeds the maximum length" do
clear_config([:instance, :description_limit], 5)
assert Filter.Exiftool.ReadDescription.filter(@uploads) ==
{:ok, :filtered, @uploads}
end
test "Ignores content with only whitespace" do
uploads = %Pleroma.Upload{
name: "non-existant.jpg",
content_type: "image/jpeg",
path:
Path.absname(
"test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg"
),
tempfile:
Path.absname(
"test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg"
),
description: nil
}
assert Filter.Exiftool.ReadDescription.filter(uploads) ==
{:ok, :filtered, uploads}
end
test "Return nil when image description from EXIF data can't be read" do
uploads = %Pleroma.Upload{
name: "non-existant.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/non-existant.jpg"),
tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg"),
description: nil
}
assert Filter.Exiftool.ReadDescription.filter(uploads) ==
{:ok, :filtered, uploads}
end
end

View file

@ -1,51 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.Exiftool.StripLocationTest do
use Pleroma.DataCase, async: true
alias Pleroma.Upload.Filter
test "apply exiftool filter" do
assert Pleroma.Utils.command_available?("exiftool")
~w{jpg png}
|> Enum.map(fn type ->
File.cp!(
"test/fixtures/DSCN0010.#{type}",
"test/fixtures/DSCN0010_tmp.#{type}"
)
upload = %Pleroma.Upload{
name: "image_with_GPS_data.#{type}",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/DSCN0010.#{type}"),
tempfile: Path.absname("test/fixtures/DSCN0010_tmp.#{type}")
}
assert Filter.Exiftool.StripLocation.filter(upload) == {:ok, :filtered}
{exif_original, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010.#{type}"])
{exif_filtered, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010_tmp.#{type}"])
assert String.match?(exif_original, ~r/GPS/)
refute String.match?(exif_filtered, ~r/GPS/)
end)
end
test "verify webp, heic, svg files are skipped" do
uploads =
~w{webp heic svg svg+xml}
|> Enum.map(fn type ->
%Pleroma.Upload{
name: "sample.#{type}",
content_type: "image/#{type}"
}
end)
uploads
|> Enum.each(fn upload ->
assert Filter.Exiftool.StripLocation.filter(upload) == {:ok, :noop}
end)
end
end

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.ExiftoolTest do
# Instances may still configure the deprecated Exiftool filter names.
# They must keep working, including for formats exiftool used to skip.
use Pleroma.DataCase, async: true
import Pleroma.Tests.ExifHelpers
for filter <- [Pleroma.Upload.Filter.Exiftool, Pleroma.Upload.Filter.Exiftool.StripLocation] do
test "#{inspect(filter)} strips GPS data from HEIC uploads" do
upload = upload_copy("strip_location_gps.heic", "image/heic")
assert gps_tags(upload.tempfile) =~ "GPS Latitude"
assert Pleroma.Upload.Filter.filter([unquote(filter)], upload) == {:ok, upload}
assert gps_tags(upload.tempfile) == ""
end
end
test "Exiftool.ReadDescription reads the description" do
upload = upload_copy("strip_location_gps.heic", "image/heic", description: nil)
assert {:ok, %{description: "a heic with a location"}} =
Pleroma.Upload.Filter.filter(
[Pleroma.Upload.Filter.Exiftool.ReadDescription],
upload
)
end
end

View file

@ -531,9 +531,9 @@ defmodule Pleroma.UserTest do
ObanHelpers.perform_all()
Pleroma.Emails.UserEmail.account_confirmation_email(registered_user)
# temporary hackney fix until hackney max_connections bug is fixed
# https://git.pleroma.social/pleroma/pleroma/-/issues/2101
|> Swoosh.Email.put_private(:hackney_options, ssl_options: [versions: [:"tlsv1.2"]])
|> Swoosh.Email.put_private(:hackney_options,
ssl_options: Pleroma.HTTP.TLS.ssl_options()
)
|> assert_email_sent()
end
@ -1197,11 +1197,32 @@ defmodule Pleroma.UserTest do
res = User.get_followers(user)
assert length(res) == 2
assert Enum.member?(res, follower_one)
assert Enum.member?(res, follower_two)
refute Enum.member?(res, not_follower)
end
test "does not return deactivated followers" do
user = insert(:user)
follower_one = insert(:user)
follower_two = insert(:user)
not_follower = insert(:user)
{:ok, follower_one, user} = User.follow(follower_one, user)
{:ok, follower_two, user} = User.follow(follower_two, user)
User.delete(follower_two)
follower_two = User.get_cached_by_id(follower_two.id)
res = User.get_followers(user)
assert length(res) == 1
assert Enum.member?(res, follower_one)
refute Enum.member?(res, follower_two)
refute Enum.member?(res, not_follower)
end
test "gets all friends (followed users) for a given user" do
user = insert(:user)
followed_one = insert(:user)
@ -1215,10 +1236,34 @@ defmodule Pleroma.UserTest do
followed_one = User.get_cached_by_ap_id(followed_one.ap_id)
followed_two = User.get_cached_by_ap_id(followed_two.ap_id)
assert length(res) == 2
assert Enum.member?(res, followed_one)
assert Enum.member?(res, followed_two)
refute Enum.member?(res, not_followed)
end
test "does not return deactivated followed users (friends)" do
user = insert(:user)
followed_one = insert(:user)
followed_two = insert(:user)
not_followed = insert(:user)
{:ok, user, followed_one} = User.follow(user, followed_one)
{:ok, user, followed_two} = User.follow(user, followed_two)
User.delete(followed_two)
res = User.get_friends(user)
followed_one = User.get_cached_by_id(followed_one.id)
followed_two = User.get_cached_by_id(followed_two.id)
assert length(res) == 1
assert Enum.member?(res, followed_one)
refute Enum.member?(res, followed_two)
refute Enum.member?(res, not_followed)
end
end
describe "updating note and follower count" do
@ -2964,6 +3009,18 @@ defmodule Pleroma.UserTest do
assert [%{id: ^id2}] = User.get_familiar_followers(user3, user1)
end
test "does not return deactivated users" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
User.follow(user1, user2)
User.follow(user2, user3)
{:ok, _} = User.set_activation(user2, false)
assert [] = User.get_familiar_followers(user3, user1)
end
end
describe "account endorsements" do

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
alias Pleroma.Activity
alias Pleroma.Delivery
alias Pleroma.Object
alias Pleroma.Signature
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
@ -49,6 +50,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
|> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"")
end
defp put_fake_signature(conn, actor_id, date \\ Signature.signed_date()) do
conn
|> put_req_header("date", date)
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{actor_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
end
defp expect_signature_retry_from(%User{} = signer) do
signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
@ -792,12 +803,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
}
}
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("content-digest", "sha-256=:fake-digest:")
|> put_req_header(
"signature-input",
@ -832,12 +843,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
legacy_signature =
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header("signature", "sig1=:fake-signature:")
|> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1])
@ -871,7 +882,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature-input",
@ -881,12 +892,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
"signature",
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
assert [_] = all_enqueued(worker: SignatureRetryWorker)
end
test "refuses a replayed signed POST instead of queueing a retry", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_fake_signature(data["actor"], Signature.signed_date(stale))
|> post("/inbox", Jason.encode!(data))
assert "error, invalid HTTP signature (date_too_old)" == json_response(conn, 401)
assert [] == all_enqueued(worker: SignatureRetryWorker)
assert [] == all_enqueued(worker: ReceiverWorker)
end
test "refuses a signed POST that does not cover the digest", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{data["actor"]}#main-key\",headers=\"(request-target) host date\",signature=\"fake\""
)
|> post("/inbox", Jason.encode!(data))
assert "error, invalid HTTP signature (digest_not_signed)" == json_response(conn, 401)
assert [] == all_enqueued(worker: SignatureRetryWorker)
end
test "does not create a forged post after failed signature retry", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
@ -918,8 +961,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -950,8 +993,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -982,8 +1025,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -1025,13 +1068,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)
@ -1063,13 +1106,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)
@ -1829,6 +1872,30 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts activities that do not address the inbox owner", %{conn: conn} do
user = insert(:user)
actor = insert(:user, local: false)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Follow",
"id" => "#{actor.ap_id}/follows/1",
"actor" => actor.ap_id,
"object" => user.ap_id,
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it returns an error when receiving an activity sent to a deactivated user", %{
conn: conn,
data: data
@ -2642,6 +2709,43 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert result == "Character limit (5 characters) exceeded, contains 11 characters"
end
test "it is rate limited", %{conn: conn, activity: activity} do
clear_config([:rate_limit, :statuses_actions], {60_000, 2})
user = insert(:user)
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
for _ <- 1..2 do
conn
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(201)
end
assert conn
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(429) == %{"error" => "Throttled"}
end
test "it shares the rate limit with the Mastodon API", %{activity: activity} do
clear_config([:rate_limit, :statuses_actions], {60_000, 2})
%{user: user, conn: conn} = oauth_access(["write"])
for _ <- 1..2 do
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{status: "cofe"})
|> json_response(200)
end
assert conn
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(429) == %{"error" => "Throttled"}
end
end
describe "/relay/followers" do
@ -3104,4 +3208,125 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
obj_id in object_ids
end)
end
describe "OAuth scopes" do
setup do
Mox.stub_with(Pleroma.StaticStubbedConfigMock, Pleroma.Config)
[user: insert(:user)]
end
defp with_token(conn, user, scopes) do
token = insert(:oauth_token, user: user, scopes: scopes)
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> put_req_header("accept", "application/activity+json")
end
test "GET /api/ap/whoami needs read:accounts", %{conn: conn, user: user} do
assert conn |> with_token(user, ["profile"]) |> get("/api/ap/whoami") |> json_response(403)
assert %{"id" => id} =
conn
|> with_token(user, ["read:accounts"])
|> get("/api/ap/whoami")
|> json_response(200)
assert id == user.ap_id
end
test "GET /users/:nickname/inbox needs read:statuses", %{conn: conn, user: user} do
inbox = "/users/#{user.nickname}/inbox"
assert conn |> with_token(user, ["read:accounts"]) |> get(inbox) |> json_response(403)
assert conn |> with_token(user, ["read:statuses"]) |> get(inbox) |> json_response(200)
end
test "POST /users/:nickname/outbox needs write", %{conn: conn, user: user} do
activity = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"object" => %{
"type" => "Note",
"content" => "scoped",
"to" => "https://www.w3.org/ns/activitystreams#Public",
"cc" => []
}
}
post = fn scopes ->
conn
|> with_token(user, scopes)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
end
assert post.(["profile", "write:media"]) |> json_response(403)
assert post.(["write"]) |> json_response(201)
end
test "POST /api/ap/upload_media is forbidden without write:media", %{conn: conn, user: user} do
assert conn
|> with_token(user, ["write:statuses"])
|> post("/api/ap/upload_media", %{})
|> json_response(403)
end
# majic's libmagic port is unavailable on local Darwin runs; Linux CI still runs this test.
@tag :skip_darwin
test "POST /api/ap/upload_media works with write:media", %{conn: conn, user: user} do
image = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
assert conn
|> with_token(user, ["write:media"])
|> post("/api/ap/upload_media", %{"file" => image})
|> json_response(201)
end
test "GET /users/:nickname/outbox shows private posts only with read:statuses",
%{conn: conn, user: user} do
{:ok, activity} = CommonAPI.post(user, %{status: "secret", visibility: "private"})
outbox = "/users/#{user.nickname}/outbox?page=true"
ids = fn scopes ->
conn
|> with_token(user, scopes)
|> get(outbox)
|> json_response(200)
|> Map.fetch!("orderedItems")
|> Enum.map(& &1["id"])
end
refute activity.data["id"] in ids.(["profile"])
assert activity.data["id"] in ids.(["read:statuses"])
end
test "GET /users/:nickname/followers shows hidden followers only with read:accounts",
%{conn: conn} do
user = insert(:user, hide_followers: true)
User.follow(insert(:user), user)
followers = "/users/#{user.nickname}/followers?page=1"
assert conn |> with_token(user, ["profile"]) |> get(followers) |> response(403)
assert %{"orderedItems" => [_]} =
conn |> with_token(user, ["read:accounts"]) |> get(followers) |> json_response(200)
end
test "GET /users/:nickname/following shows hidden follows only with read:accounts",
%{conn: conn} do
user = insert(:user, hide_follows: true)
User.follow(user, insert(:user))
following = "/users/#{user.nickname}/following?page=1"
assert conn |> with_token(user, ["profile"]) |> get(following) |> response(403)
assert %{"orderedItems" => [_]} =
conn |> with_token(user, ["read:accounts"]) |> get(following) |> json_response(200)
end
end
end

View file

@ -184,6 +184,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert user.follower_address == "http://mastodon.example.org/users/admin/followers"
end
test "it records the user's instance" do
refute Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org")
{:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin")
assert %{unreachable_since: nil} =
Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org")
end
test "it records the instance of a known user when refreshing it" do
insert(:user, local: false, ap_id: "http://mastodon.example.org/users/admin")
refute Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org")
{:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin")
assert Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org")
end
test "it keeps an existing instance record" do
unreachable_since = ~N[2020-01-01 00:00:00.000000]
insert(:instance, host: "mastodon.example.org", unreachable_since: unreachable_since)
{:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin")
assert %{unreachable_since: ^unreachable_since} =
Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org")
end
test "it returns a user that is invisible" do
user_id = "http://mastodon.example.org/users/relay"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
@ -244,6 +272,58 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
}
end
test "rejects actors whose inbox is on another host" do
ap_id = "https://example.com/users/evil"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "evil")
|> Jason.decode!()
|> Map.put("inbox", "https://victim.example/inbox")
|> Map.put("featured", nil)
|> Jason.encode!()
Tesla.Mock.mock(fn
%{method: :get, url: ^ap_id} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
assert {:error, _} = ActivityPub.make_user_from_ap_id(ap_id)
refute User.get_by_ap_id(ap_id)
end
test "updates the known user when fetched from a display URL" do
ap_id = "https://example.com/users/lain"
display_url = "https://example.com/@lain"
user = insert(:user, local: false, ap_id: ap_id, nickname: "lain@example.com")
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
|> Map.merge(%{"featured" => nil, "name" => "new name"})
|> Jason.encode!()
Tesla.Mock.mock(fn %{method: :get, url: url} when url in [ap_id, display_url] ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
assert {:ok, %User{id: id, name: "new name"}} =
ActivityPub.make_user_from_ap_id(display_url)
assert id == user.id
end
test "fetches user featured collection" do
ap_id = "https://example.com/users/lain"
@ -516,7 +596,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
test "fetches user birthday information from misskey" do
user_id = "https://misskey.io/@mkljczk"
user_id = "https://misskey.io/users/8dhi2ne167"
Tesla.Mock.mock(fn
%{
@ -2033,7 +2113,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/followers"
"id" => "https://remote.org/users/masto_hidden_counters/following"
},
headers: HttpRequestMock.activitypub_object_headers()
)
@ -2045,7 +2125,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/following"
"id" => "https://remote.org/users/masto_hidden_counters/followers"
},
headers: HttpRequestMock.activitypub_object_headers()
)

View file

@ -8,6 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do
alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy
alias Pleroma.Web.ActivityPub.Visibility
require Pleroma.Constants
setup do:
clear_config(:mrf_object_age,
threshold: 172_800,
@ -79,7 +81,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do
{:ok, data} = ObjectAgePolicy.filter(data)
assert Visibility.get_visibility(%{data: data}) == "unlisted"
assert Visibility.get_visibility(%{data: data}) == "direct"
end
test "it delists an old post" do
@ -94,6 +96,90 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do
assert Visibility.get_visibility(%{data: data}) == "unlisted"
end
test "it keeps an old followers-only post private" do
clear_config([:mrf_object_age, :actions], [:delist])
data = get_old_message()
{:ok, user} = User.get_or_fetch_by_ap_id(data["actor"])
data =
data
|> Map.put("to", [user.follower_address])
|> Map.put("cc", [])
|> put_in(["object", "to"], [user.follower_address])
|> put_in(["object", "cc"], [])
{:ok, data} = ObjectAgePolicy.filter(data)
assert data["to"] == [user.follower_address]
assert data["cc"] == []
assert Visibility.get_visibility(%{data: data}) == "private"
assert data["object"]["to"] == data["to"]
assert data["object"]["cc"] == data["cc"]
end
test "it keeps an old direct message direct" do
clear_config([:mrf_object_age, :actions], [:delist])
data = get_old_message()
{:ok, _user} = User.get_or_fetch_by_ap_id(data["actor"])
recipient = "https://example.com/users/someone"
data =
data
|> Map.put("to", [recipient])
|> Map.put("cc", [])
|> put_in(["object", "to"], [recipient])
|> put_in(["object", "cc"], [])
{:ok, data} = ObjectAgePolicy.filter(data)
assert data["to"] == [recipient]
assert data["cc"] == []
assert Visibility.get_visibility(%{data: data}) == "direct"
end
test "it removes every copy of the follower address" do
clear_config([:mrf_object_age, :actions], [:strip_followers])
data = get_old_message()
{:ok, user} = User.get_or_fetch_by_ap_id(data["actor"])
public = Pleroma.Constants.as_public()
followers = [user.follower_address, user.follower_address]
data =
data
|> Map.put("to", [public])
|> Map.put("cc", followers)
|> put_in(["object", "to"], [public])
|> put_in(["object", "cc"], followers)
{:ok, data} = ObjectAgePolicy.filter(data)
assert data["to"] == [public]
assert data["cc"] == []
end
test "it does not duplicate addresses of an old unlisted post" do
clear_config([:mrf_object_age, :actions], [:delist, :strip_followers])
data = get_old_message()
{:ok, user} = User.get_or_fetch_by_ap_id(data["actor"])
public = Pleroma.Constants.as_public()
data =
data
|> Map.put("to", [user.follower_address])
|> Map.put("cc", [public])
|> put_in(["object", "to"], [user.follower_address])
|> put_in(["object", "cc"], [public])
{:ok, data} = ObjectAgePolicy.filter(data)
assert data["to"] == []
assert data["cc"] == [public]
end
test "it allows a new post" do
clear_config([:mrf_object_age, :actions], [:delist])

View file

@ -6,8 +6,6 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
use ExUnit.Case
use Pleroma.Tests.Helpers
import ExUnit.CaptureLog
alias Pleroma.Web.ActivityPub.MRF
defp regexes_match!([], []), do: true
@ -21,14 +19,14 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
test "subdomains_regex/1" do
regexes = MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"])
assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^(.*\.)*unsafe.tld$/i])
assert regexes_match!(regexes, [~r/^unsafe\.tld$/i, ~r/^(.*\.)?unsafe\.tld$/i])
end
describe "subdomain_match/2" do
test "common domains" do
regexes = MRF.subdomains_regex(["unsafe.tld", "unsafe2.tld"])
assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^unsafe2.tld$/i])
assert regexes_match!(regexes, [~r/^unsafe\.tld$/i, ~r/^unsafe2\.tld$/i])
assert MRF.subdomain_match?(regexes, "unsafe.tld")
assert MRF.subdomain_match?(regexes, "unsafe2.tld")
@ -39,7 +37,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
test "wildcard domains with one subdomain" do
regexes = MRF.subdomains_regex(["*.unsafe.tld"])
assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i])
assert regexes_match!(regexes, [~r/^(.*\.)?unsafe\.tld$/i])
assert MRF.subdomain_match?(regexes, "unsafe.tld")
assert MRF.subdomain_match?(regexes, "sub.unsafe.tld")
@ -50,7 +48,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
test "wildcard domains with two subdomains" do
regexes = MRF.subdomains_regex(["*.unsafe.tld"])
assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i])
assert regexes_match!(regexes, [~r/^(.*\.)?unsafe\.tld$/i])
assert MRF.subdomain_match?(regexes, "unsafe.tld")
assert MRF.subdomain_match?(regexes, "sub.sub.unsafe.tld")
@ -61,7 +59,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
test "matches are case-insensitive" do
regexes = MRF.subdomains_regex(["UnSafe.TLD", "UnSAFE2.Tld"])
assert regexes_match!(regexes, [~r/^UnSafe.TLD$/i, ~r/^UnSAFE2.Tld$/i])
assert regexes_match!(regexes, [~r/^UnSafe\.TLD$/i, ~r/^UnSAFE2\.Tld$/i])
assert MRF.subdomain_match?(regexes, "UNSAFE.TLD")
assert MRF.subdomain_match?(regexes, "UNSAFE2.TLD")
@ -72,11 +70,20 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
refute MRF.subdomain_match?(regexes, "example.com")
end
test "logs sensible error on accidental wildcard" do
assert_raise Regex.CompileError, fn ->
assert capture_log(MRF.subdomains_regex(["*unsafe.tld"])) =~
"MRF: Invalid subdomain Regex: *unsafe.tld"
end
test "dots only match dots" do
regexes = MRF.subdomains_regex(["unsafe.tld", "*.other.tld"])
refute MRF.subdomain_match?(regexes, "unsafextld")
refute MRF.subdomain_match?(regexes, "otherxtld")
refute MRF.subdomain_match?(regexes, "sub.otherxtld")
end
test "accidental wildcards and regex syntax match nothing" do
regexes = MRF.subdomains_regex(["*unsafe.tld", "bad(.tld"])
refute MRF.subdomain_match?(regexes, "unsafe.tld")
refute MRF.subdomain_match?(regexes, "sub.unsafe.tld")
refute MRF.subdomain_match?(regexes, "bad.tld")
end
end
@ -139,4 +146,37 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
type: :group
}
end
describe "instance_allowed?/1" do
setup do: clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
setup do: clear_config([:mrf_simple, :reject], [{"rejected.example", "spam"}])
setup do: clear_config([:mrf_simple, :accept], [])
test "is false for hosts rejected by SimplePolicy, including subdomains" do
clear_config([:mrf_simple, :reject], [{"*.rejected.example", "spam"}])
refute MRF.instance_allowed?("rejected.example")
refute MRF.instance_allowed?("sub.rejected.example")
assert MRF.instance_allowed?("fine.example")
end
test "only allows accepted hosts when SimplePolicy has an accept list" do
clear_config([:mrf_simple, :accept], [{"friend.example", ""}])
assert MRF.instance_allowed?("friend.example")
refute MRF.instance_allowed?("stranger.example")
end
test "ignores the SimplePolicy lists when the policy is not enabled" do
clear_config([:mrf, :policies], [])
clear_config([:mrf_simple, :accept], [{"friend.example", ""}])
assert MRF.instance_allowed?("rejected.example")
assert MRF.instance_allowed?("stranger.example")
end
test "is false without a host" do
refute MRF.instance_allowed?(nil)
end
end
end

View file

@ -216,6 +216,39 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
assert content =~ "@uncached"
end
test "a Misskey MFM remote mention is not rendered as an email link" do
remote_user = insert(:user, ap_id: "https://mk.absturztau.be/users/alice")
mentioned_user = insert(:user, ap_id: "https://movsw.0x0.st/users/bob")
note = %{
"id" => "https://mk.absturztau.be/notes/1",
"type" => "Note",
"actor" => remote_user.ap_id,
"attributedTo" => remote_user.ap_id,
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [mentioned_user.ap_id],
"content" => ~s(<p>@<a href="mailto:bob@movsw.0x0.st">bob@movsw.0x0.st</a> hello</p>),
"context" => Utils.generate_context_id(),
"tag" => [
%{
"type" => "Mention",
"name" => "@bob@movsw.0x0.st",
"href" => mentioned_user.ap_id
}
],
"source" => %{
"content" => "@bob@movsw.0x0.st hello",
"mediaType" => "text/x.misskeymarkdown"
}
}
%{valid?: true, changes: %{content: content}} =
ArticleNotePageValidator.cast_and_validate(note)
assert content =~ ~s(href="#{mentioned_user.ap_id}")
refute content =~ "mailto:"
end
test "a Misskey MFM note drops oversized source content instead of parsing it" do
user = insert(:user, ap_id: "https://misskey.example/users/oversized")

View file

@ -0,0 +1,75 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidatorTest do
use ExUnit.Case, async: true
alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator
defp actor do
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
end
test "accepts an actor whose endpoints and collections are on its own host" do
data = actor()
assert {:ok, ^data, []} = UserValidator.validate(data, [])
end
test "accepts an actor without optional fields" do
data = Map.drop(actor(), ["endpoints", "featured", "followers", "following", "publicKey"])
assert {:ok, _, _} = UserValidator.validate(data, [])
end
test "accepts all actor fixtures" do
for path <- Path.wildcard("test/fixtures/**/*.json"),
{:ok, %{"inbox" => _, "id" => id} = data} when is_binary(id) <-
[Jason.decode(File.read!(path))] do
assert {{:ok, _, _}, ^path} = {UserValidator.validate(data, []), path}
end
end
test "treats empty and null fields as missing" do
data =
actor()
|> Map.merge(%{"inbox" => "", "followers" => nil, "following" => "", "featured" => ""})
|> put_in(["endpoints", "sharedInbox"], "")
|> put_in(["publicKey", "id"], "")
assert {:ok, _, _} = UserValidator.validate(data, [])
end
for field <- ["inbox", "followers", "following", "featured"] do
test "rejects an actor whose #{field} is on another host" do
data = Map.put(actor(), unquote(field), "https://victim.example/users/lain/x")
assert {:error, _} = UserValidator.validate(data, [])
end
end
test "rejects an actor whose sharedInbox is on another host" do
data = put_in(actor(), ["endpoints", "sharedInbox"], "https://victim.example/inbox")
assert {:error, _} = UserValidator.validate(data, [])
end
test "rejects an actor whose collection is an object on another host" do
data = Map.put(actor(), "followers", %{"id" => "https://victim.example/users/v/followers"})
assert {:error, _} = UserValidator.validate(data, [])
end
test "rejects an actor whose key id is on another host" do
data = put_in(actor(), ["publicKey", "id"], "https://victim.example/users/lain#main-key")
assert {:error, _} = UserValidator.validate(data, [])
end
test "rejects an actor whose key id has no host" do
data = put_in(actor(), ["publicKey", "id"], "lain")
assert {:error, _} = UserValidator.validate(data, [])
end
test "rejects an actor without an id" do
assert {:error, _} = UserValidator.validate(Map.delete(actor(), "id"), [])
end
end

View file

@ -32,8 +32,26 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
refute Publisher.should_federate?(nil, true)
end
test "it returns true when public is true" do
assert Publisher.should_federate?(false, true)
test "it returns true for public activities to quarantined instances" do
clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}])
assert Publisher.should_federate?("https://quarantined.example/inbox", true)
refute Publisher.should_federate?("https://quarantined.example/inbox", false)
end
test "it returns false for instances rejected by SimplePolicy, even for public activities" do
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
clear_config([:mrf_simple, :reject], [{"rejected.example", ""}])
refute Publisher.should_federate?("https://rejected.example/inbox", true)
refute Publisher.should_federate?("https://rejected.example/inbox", false)
assert Publisher.should_federate?("https://fine.example/inbox", true)
end
test "it returns false for inboxes without a host" do
clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}])
refute Publisher.should_federate?("inbox", false)
end
end
@ -119,7 +137,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox"
end
test "it returns inbox for messages involving single recipients in total" do
test "it returns sharedInbox for messages to a single recipient" do
user =
insert(:user, %{
shared_inbox: "http://example.com/inbox",
@ -130,6 +148,46 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
data: %{"to" => [user.ap_id], "cc" => []}
}
assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox"
end
test "it returns sharedInbox for followers-only messages" do
user =
insert(:user, %{
shared_inbox: "http://example.com/inbox",
inbox: "http://example.com/personal-inbox"
})
author = insert(:user)
activity = %Activity{
data: %{"to" => [author.follower_address], "cc" => []}
}
assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox"
end
test "it returns inbox for messages with bcc or bto, which needs personal inboxes" do
user =
insert(:user, %{
shared_inbox: "http://example.com/inbox",
inbox: "http://example.com/personal-inbox"
})
for field <- ["bcc", "bto"] do
activity = %Activity{
data: %{"to" => [@as_public], "cc" => [], field => [user.ap_id]}
}
assert Publisher.determine_inbox(activity, user) == "http://example.com/personal-inbox"
end
end
test "it returns inbox when the user has no sharedInbox" do
user = insert(:user, %{shared_inbox: nil, inbox: "http://example.com/personal-inbox"})
activity = %Activity{data: %{"to" => [@as_public], "cc" => []}}
assert Publisher.determine_inbox(activity, user) == "http://example.com/personal-inbox"
end
end
@ -204,6 +262,32 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
)
end
test "doesn't publish a public activity to instances rejected by SimplePolicy" do
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
clear_config([:mrf_simple, :reject], [{"rejected.example", "spam"}])
actor = insert(:user)
for host <- ["rejected.example", "fine.example"] do
follower = insert(:user, local: false, inbox: "https://#{host}/users/nick/inbox")
{:ok, _, _} = Pleroma.User.follow(follower, actor)
end
actor = refresh_record(actor)
note_activity =
insert(:note_activity, user: actor, recipients: [@as_public, actor.follower_address])
assert :ok == Publisher.publish(actor, note_activity)
inboxes =
all_enqueued(worker: "Pleroma.Workers.PublisherWorker")
|> Enum.map(& &1.args["params"]["inbox"])
assert "https://fine.example/users/nick/inbox" in inboxes
refute "https://rejected.example/users/nick/inbox" in inboxes
end
test "Publishes a non-public activity to non-quarantined instances." do
Config.put([:instance, :quarantined_instances], [{"somedomain.com", "some reason"}])

View file

@ -86,6 +86,31 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do
)
end
test "it works for incoming custom emoji reactions with a single tag object" do
user = insert(:user)
other_user = insert(:user, local: false)
{:ok, activity} = CommonAPI.post(user, %{status: "hello"})
data =
File.read!("test/fixtures/custom-emoji-reaction.json")
|> Jason.decode!()
|> Map.put("object", activity.data["object"])
|> Map.put("actor", other_user.ap_id)
[tag] = data["tag"]
data = Map.put(data, "tag", tag)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["content"] == ":hanapog:"
assert [%{"name" => "hanapog"}] = data["tag"]
object = Object.get_by_ap_id(data["object"])
url = tag["icon"]["url"]
assert match?([["hanapog", _, ^url]], object.data["reactions"])
end
test "it works for incoming unqualified emoji reactions" do
user = insert(:user)
other_user = insert(:user, local: false)

View file

@ -103,6 +103,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do
assert Enum.sort(object.data["oneOf"]) == Enum.sort(options)
end
test "Question activity with a single, non-array tag" do
tag = %{
"href" => "https://gts.example/tags/caturday",
"name" => "#caturday",
"type" => "Hashtag"
}
data =
File.read!("test/fixtures/mastodon-question-activity.json")
|> Jason.decode!()
|> Kernel.put_in(["object", "tag"], tag)
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity, fetch: false)
assert tag in object.data["tag"]
assert Object.hashtags(object) == ["caturday"]
end
test "Mastodon Question activity with custom emojis" do
options = [
%{

View file

@ -11,8 +11,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
import Pleroma.Factory
# matches the host of the actor in mastodon-update.json
@gargron "http://mastodon.example.org/users/gargron"
test "it works for incoming update activities" do
user = insert(:user, local: false)
user = insert(:user, local: false, ap_id: @gargron)
update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!()
@ -50,8 +53,28 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
assert user.bio == "<p>Some bio</p>"
end
test "it rejects updates that move the shared inbox to another host" do
user = insert(:user, local: false, ap_id: @gargron)
update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!()
object =
update_data["object"]
|> Map.put("actor", user.ap_id)
|> Map.put("id", user.ap_id)
|> put_in(["endpoints", "sharedInbox"], "https://victim.example/inbox")
update_data =
update_data
|> Map.put("actor", user.ap_id)
|> Map.put("object", object)
assert {:error, _} = Transmogrifier.handle_incoming(update_data)
assert User.get_cached_by_ap_id(user.ap_id).shared_inbox == user.shared_inbox
end
test "it selects an actor type after an extension type" do
user = insert(:user, local: false)
user = insert(:user, local: false, ap_id: @gargron)
update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!()
object =
@ -70,7 +93,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
end
test "it works with alsoKnownAs" do
%{ap_id: actor} = insert(:user, local: false)
%{ap_id: actor} = insert(:user, local: false, ap_id: @gargron)
assert User.get_cached_by_ap_id(actor).also_known_as == []
@ -97,7 +120,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
end
test "it works with custom profile fields" do
user = insert(:user, local: false)
user = insert(:user, local: false, ap_id: @gargron)
assert user.fields == []
@ -155,7 +178,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
end
test "it works for incoming update activities which lock the account" do
user = insert(:user, local: false)
user = insert(:user, local: false, ap_id: @gargron)
update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!()

View file

@ -147,6 +147,53 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert activity.data["type"] == "Move"
end
test "it refetches a cached remote Move target that lacks the alias" do
old_user = insert(:user, local: false, ap_id: "https://old.example/users/old")
new_user =
insert(:user,
local: false,
ap_id: "https://new.example/users/new",
nickname: "new@new.example",
last_refreshed_at: NaiveDateTime.utc_now()
)
actor = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => new_user.ap_id,
"type" => "Person",
"preferredUsername" => "new",
"inbox" => new_user.ap_id <> "/inbox",
"outbox" => new_user.ap_id <> "/outbox",
"followers" => new_user.ap_id <> "/followers",
"following" => new_user.ap_id <> "/following",
"alsoKnownAs" => [old_user.ap_id]
}
Tesla.Mock.mock(fn
%{method: :get, url: "https://new.example/users/new"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body: Jason.encode!(actor)
}
_ ->
%Tesla.Env{status: 404}
end)
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Move",
"actor" => old_user.ap_id,
"object" => old_user.ap_id,
"target" => new_user.ap_id
}
assert {:ok, %Activity{data: %{"type" => "Move"}}} = Transmogrifier.handle_incoming(message)
assert old_user.ap_id in User.get_cached_by_ap_id(new_user.ap_id).also_known_as
end
test "it fixes both the Create and object contexts in a reply" do
insert(:user, ap_id: "https://mk.absturztau.be/users/8ozbzjs3o8")
insert(:user, ap_id: "https://p.helene.moe/users/helene")

View file

@ -406,74 +406,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
end
end
describe "recipient_in_message/3" do
test "returns true when recipient in `to`" do
recipient = insert(:user)
actor = insert(:user)
assert Utils.recipient_in_message(recipient, actor, %{"to" => recipient.ap_id})
assert Utils.recipient_in_message(
recipient,
actor,
%{"to" => [recipient.ap_id], "cc" => ""}
)
end
test "returns true when recipient in `cc`" do
recipient = insert(:user)
actor = insert(:user)
assert Utils.recipient_in_message(recipient, actor, %{"cc" => recipient.ap_id})
assert Utils.recipient_in_message(
recipient,
actor,
%{"cc" => [recipient.ap_id], "to" => ""}
)
end
test "returns true when recipient in `bto`" do
recipient = insert(:user)
actor = insert(:user)
assert Utils.recipient_in_message(recipient, actor, %{"bto" => recipient.ap_id})
assert Utils.recipient_in_message(
recipient,
actor,
%{"bcc" => "", "bto" => [recipient.ap_id]}
)
end
test "returns true when recipient in `bcc`" do
recipient = insert(:user)
actor = insert(:user)
assert Utils.recipient_in_message(recipient, actor, %{"bcc" => recipient.ap_id})
assert Utils.recipient_in_message(
recipient,
actor,
%{"bto" => "", "bcc" => [recipient.ap_id]}
)
end
test "returns true when message without addresses fields" do
recipient = insert(:user)
actor = insert(:user)
assert Utils.recipient_in_message(recipient, actor, %{"bccc" => recipient.ap_id})
assert Utils.recipient_in_message(
recipient,
actor,
%{"btod" => "", "bccc" => [recipient.ap_id]}
)
end
test "returns false" do
recipient = insert(:user)
actor = insert(:user)
refute Utils.recipient_in_message(recipient, actor, %{"to" => "ap_id"})
end
end
describe "lazy_put_activity_defaults/2" do
test "returns map with id and published data" do
note_activity = insert(:note_activity)

View file

@ -1042,9 +1042,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
ObanHelpers.perform_all()
Pleroma.Emails.UserEmail.account_confirmation_email(first_user)
# temporary hackney fix until hackney max_connections bug is fixed
# https://git.pleroma.social/pleroma/pleroma/-/issues/2101
|> Swoosh.Email.put_private(:hackney_options, ssl_options: [versions: [:"tlsv1.2"]])
|> Swoosh.Email.put_private(:hackney_options,
ssl_options: Pleroma.HTTP.TLS.ssl_options()
)
|> assert_email_sent()
end

View file

@ -60,6 +60,28 @@ defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do
assert key2 == inspect(config2.key)
end
test "does not crash on a setting with an unknown module key", %{conn: conn} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Pleroma.Repo.insert_all("config", [
%{
group: ":pleroma",
key: "Pleroma.Nowhere.Module",
value: :erlang.term_to_binary(enabled: true),
inserted_at: now,
updated_at: now
}
])
capture_log(fn ->
for path <- ["/api/pleroma/admin/config", "/api/pleroma/admin/config?only_db=true"] do
conn
|> get(path)
|> json_response_and_validate_schema(200)
end
end)
end
test "db is added to settings that are in db", %{conn: conn} do
_config = insert(:config, key: ":instance", value: [name: "Some name"])

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.CastAndValidateTest do
use Pleroma.Web.ConnCase, async: false
describe "permissive mode" do
setup do: clear_config([Pleroma.Web.ApiSpec.CastAndValidate, :strict], false)
test "drops several unexpected query params", %{conn: conn} do
assert conn
|> get("/api/v1/timelines/public?foo=1&bar=2&baz=3&local=true")
|> json_response_and_validate_schema(200)
end
test "still rejects an invalid known param", %{conn: conn} do
assert %{"error" => _} =
conn
|> get("/api/v1/timelines/public?foo=1&bar=2&limit=lots")
|> json_response(400)
end
end
end

View file

@ -125,6 +125,14 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert output == expected
end
test "MFM keeps bare URLs containing Markdown delimiters intact" do
url = "https://example.org/a*b*c"
{output, [], []} = Utils.format_input(url, "text/x.misskeymarkdown")
assert output == ~s(<p><a href="#{url}">#{url}</a></p>)
end
test "works for bare text/bbcode" do
text = "[b]hello world[/b]"
expected = "<strong>hello world</strong>"
@ -220,6 +228,15 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert result == ~s[<a href="http://example.org/">OwO</a>]
end
test "markdown links do not allow injected attributes" do
code = ~s|[click](http://example.com/?a=x" onerror="alert(1))|
{result, [], []} = Utils.format_input(code, "text/markdown")
assert result =~ ~s|<a href="http://example.com/?a=x%22 onerror=%22alert(1)">|
refute result =~ ~s|onerror="alert(1)|
refute result =~ ~s(<a href="http://example.com/?a=x" onerror=)
end
test "rulers" do
code = ~s[before\n\n-----\n\nafter]
{result, [], []} = Utils.format_input(code, "text/markdown")

View file

@ -338,6 +338,26 @@ defmodule Pleroma.Web.CommonAPITest do
assert {:ok, :no_activity} == CommonAPI.unblock(blocked, blocker)
refute User.blocks?(blocker, blocked)
end
test "it unblocks and does not federate if outgoing blocks are disabled" do
clear_config([:instance, :federating], true)
clear_config([:activitypub, :outgoing_blocks], false)
blocked = insert(:user)
blocker = insert(:user)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, block} = CommonAPI.block(blocked, blocker)
assert block.local
assert User.blocks?(blocker, blocked)
assert {:ok, unblock} = CommonAPI.unblock(blocked, blocker)
assert unblock.local
refute User.blocks?(blocker, blocked)
assert_not_called(Pleroma.Web.Federator.publish(:_))
end
end
end
describe "deletion" do

View file

@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.SetLocalePlug
@ -2033,6 +2034,60 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
assert id == to_string(user.id)
end
test "the profile scope only gets the public account" do
user = insert(:user, default_scope: "private", pleroma_settings_store: %{"fe" => %{}})
%{conn: conn} = oauth_access(["profile"], user: user)
response =
conn
|> get("/api/v1/accounts/verify_credentials")
|> json_response_and_validate_schema(200)
assert response ==
AccountView.render("show.json", user: user, skip_visibility_check: true)
|> Jason.encode!()
|> Jason.decode!()
refute Map.has_key?(response["source"], "privacy")
refute Map.has_key?(response, "follow_requests_count")
for key <- ~w(email settings_store chat_token notification_settings
unread_notifications_count unread_conversation_count allow_following_move) do
refute Map.has_key?(response["pleroma"], key), "#{key} must not be shown"
end
end
test "the profile scope with read:accounts gets the full account" do
%{conn: conn} = oauth_access(["profile", "read:accounts"])
response =
conn
|> get("/api/v1/accounts/verify_credentials")
|> json_response_and_validate_schema(200)
assert response["source"]["privacy"]
assert response["pleroma"]["chat_token"]
end
test "the profile scope works when unauthenticated users cannot see profiles" do
clear_config([:restrict_unauthenticated, :profiles, :local], true)
%{user: user, conn: conn} = oauth_access(["profile"])
assert %{"id" => id} =
conn
|> get("/api/v1/accounts/verify_credentials")
|> json_response_and_validate_schema(200)
assert id == to_string(user.id)
end
test "the profile scope does not give access to other endpoints" do
%{conn: conn} = oauth_access(["profile"])
assert conn |> get("/api/v1/timelines/home") |> json_response(403)
assert conn |> get("/api/v1/follow_requests") |> json_response(403)
end
end
describe "user relationships" do

View file

@ -63,8 +63,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
user2 = insert(:user, %{local: true})
{:ok, _user2} = User.set_activation(user2, false)
insert(:user, %{local: false, nickname: "u@peer1.com"})
insert(:user, %{local: false, nickname: "u@peer2.com"})
insert(:instance, host: "peer1.com")
insert(:instance, host: "peer2.com")
{:ok, _} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"})
@ -83,8 +83,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
end
test "get peers", %{conn: conn} do
insert(:user, %{local: false, nickname: "u@peer1.com"})
insert(:user, %{local: false, nickname: "u@peer2.com"})
# Peers come from the instances table, which remote users add to.
insert(:instance, host: "peer1.com")
insert(:instance, host: "peer2.com")
# Favicon lookups for local accounts can add our own host.
insert(:instance, host: Pleroma.Web.Endpoint.host())
Pleroma.Stats.force_update()

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
alias Pleroma.Notification
alias Pleroma.Repo
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
import Pleroma.Factory
@ -33,7 +34,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
assert response == %{
"notifications" => %{
"last_read_id" => "69420",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"updated_at" => Utils.to_masto_date(marker.updated_at),
"version" => 0,
"pleroma" => %{"unread_count" => 7}
}
@ -107,7 +108,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
assert response == %{
"notifications" => %{
"last_read_id" => "69888",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"updated_at" => Utils.to_masto_date(marker.updated_at),
"version" => 0,
"pleroma" => %{"unread_count" => 0}
}

View file

@ -328,7 +328,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert status["id"] == to_string(activity.id)
end
test "search fetches remote statuses and prefers them over other results", %{conn: conn} do
test "search fetches remote statuses and prefers them over other results" do
%{conn: conn} = oauth_access(["read:search"])
{:ok, %{id: activity_id}} =
CommonAPI.post(insert(:user), %{
status: "check out http://mastodon.example.org/@admin/99541947525187367"
@ -336,7 +338,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
%{"url" => result_url, "id" => result_id} =
conn
|> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
|> get(
"/api/v1/search?resolve=true&q=http://mastodon.example.org/@admin/99541947525187367"
)
|> json_response_and_validate_schema(200)
|> Map.get("statuses")
|> List.first()
@ -345,6 +349,28 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367")
end
test "search fetches remote statuses only with resolve, when logged in, on the first page",
%{conn: anonymous} do
%{conn: conn} = oauth_access(["read:search"])
url = "http://mastodon.example.org/@admin/99541947525187367"
for {conn, params} <- [
{conn, ""},
{conn, "&resolve=false"},
{conn, "&resolve=true&offset=5"},
{anonymous, "&resolve=true"}
] do
assert %{"statuses" => []} =
conn
|> get("/api/v1/search?q=#{url}#{params}")
|> json_response_and_validate_schema(200)
end
refute Pleroma.Object.get_by_ap_id(
"http://mastodon.example.org/users/admin/statuses/99541947525187367"
)
end
test "search doesn't show statuses that it shouldn't", %{conn: conn} do
{:ok, activity} =
CommonAPI.post(insert(:user), %{

View file

@ -2243,6 +2243,31 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
|> json_response_and_validate_schema(404) == %{"error" => "Record not found"}
end
describe "status_id_action rate limit" do
setup do: clear_config([:rate_limit, :status_id_action], {60_000, 2})
setup do: clear_config([:rate_limit, :statuses_actions], {60_000, 100})
setup do: oauth_access(["write:statuses", "write:favourites"])
for {action, undo} <- [{"favourite", "unfavourite"}, {"reblog", "unreblog"}],
strict <- [true, false] do
test "#{action} is limited per status, not per user (strict: #{strict})", %{conn: conn} do
clear_config([Pleroma.Web.ApiSpec.CastAndValidate, :strict], unquote(strict))
conn = put_req_header(conn, "content-type", "application/json")
statuses = insert_list(3, :note_activity)
assert Enum.map(
statuses,
&post(conn, "/api/v1/statuses/#{&1.id}/#{unquote(action)}").status
) ==
[200, 200, 200]
status = hd(statuses)
assert post(conn, "/api/v1/statuses/#{status.id}/#{unquote(undo)}").status == 200
assert post(conn, "/api/v1/statuses/#{status.id}/#{unquote(action)}").status == 429
end
end
end
describe "pinned statuses" do
setup do: oauth_access(["write:accounts"])
@ -3068,6 +3093,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
} = response
end
test "context shows local-only replies to local users only" do
poster = insert(:user)
replier = insert(:user)
%{conn: conn} = oauth_access(["read:statuses"])
%{conn: remote_conn} = oauth_access(["read:statuses"], user: insert(:user, local: false))
{:ok, %{id: id1}} = CommonAPI.post(poster, %{status: "1"})
{:ok, %{id: id2}} =
CommonAPI.post(replier, %{status: "2", visibility: "local", in_reply_to_status_id: id1})
assert %{"descendants" => [%{"id" => ^id2}]} =
conn
|> get("/api/v1/statuses/#{id1}/context")
|> json_response_and_validate_schema(:ok)
for conn <- [build_conn(), remote_conn] do
assert %{"descendants" => []} =
conn
|> get("/api/v1/statuses/#{id1}/context")
|> json_response_and_validate_schema(:ok)
end
end
test "context doesn't leak priv posts" do
%{user: user, conn: conn} = oauth_access(["read:statuses"])
stranger = insert(:user)

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
use Pleroma.DataCase, async: true
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.MarkerView
import Pleroma.Factory
@ -14,16 +15,25 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{
"home" => %{
last_read_id: "42",
updated_at: NaiveDateTime.to_iso8601(marker2.updated_at),
updated_at: Utils.to_masto_date(marker2.updated_at),
version: 0,
pleroma: %{unread_count: 0}
},
"notifications" => %{
last_read_id: "17",
updated_at: NaiveDateTime.to_iso8601(marker1.updated_at),
updated_at: Utils.to_masto_date(marker1.updated_at),
version: 0,
pleroma: %{unread_count: 5}
}
}
end
test "renders updated_at in the Mastodon date format" do
marker = insert(:marker, timeline: "home")
%{"home" => %{updated_at: updated_at}} =
MarkerView.render("markers.json", %{markers: [marker]})
assert updated_at =~ ~r/\.\d{3}Z$/
end
end

View file

@ -345,7 +345,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
bookmark_folder: nil,
list_id: nil
},
quotes_count: 0
quotes_count: 0,
quote: nil
}
assert status == expected
@ -507,6 +508,63 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
refute status.pleroma.quote_visible
end
describe "Mastodon quote entity" do
test "a quote has the quoted status, and quoted statuses only a shallow quote" do
post = insert(:note_activity)
user = insert(:user)
{:ok, quote_post} = CommonAPI.post(user, %{status: "he", quoted_status_id: post.id})
{:ok, quoted_quote_post} =
CommonAPI.post(user, %{status: "yo", quoted_status_id: quote_post.id})
status = StatusView.render("show.json", %{activity: quoted_quote_post})
assert %{state: "accepted", quoted_status: %{id: quoted_id} = quoted} = status.quote
assert quoted_id == to_string(quote_post.id)
assert quoted.quote == %{state: "accepted", quoted_status_id: to_string(post.id)}
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a quote of a post the user cannot see is unauthorized" do
user = insert(:user)
private = insert(:followers_only_note_activity, user: user)
quote_private =
insert(:note_activity,
note: insert(:note, data: %{"quoteUrl" => Object.normalize(private).data["id"]})
)
status = StatusView.render("show.json", %{activity: quote_private})
assert status.quote == %{state: "unauthorized", quoted_status: nil}
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
follower = insert(:user)
CommonAPI.follow(user, follower)
status = StatusView.render("show.json", %{activity: quote_private, for: follower})
assert %{state: "accepted", quoted_status: %{id: id}} = status.quote
assert id == to_string(private.id)
end
test "a quote of a missing post is deleted" do
quote_missing =
insert(:note_activity,
note: insert(:note, data: %{"quoteUrl" => "https://example.com/objects/gone"})
)
status = StatusView.render("show.json", %{activity: quote_missing})
assert status.quote == %{state: "deleted", quoted_status: nil}
end
test "a post without a quote has no quote" do
status = StatusView.render("show.json", %{activity: insert(:note_activity)})
assert status.quote == nil
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
end
test "repost of quote post" do
post = insert(:note_activity)
user = insert(:user)
@ -559,6 +617,46 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert mention.url == recipient_ap_id
end
test "inlined images are media proxied" do
ConfigMock |> stub_with(Pleroma.Test.StaticConfig)
clear_config([:media_proxy, :enabled], true)
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
content_type: "text/html",
status: "hii <img src=\"https://example.org/a.png\" />"
})
activity = Repo.get(Activity, activity.id)
status = StatusView.render("show.json", activity: activity)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:content] =~
~r/^hii <img src="https?:\/\/[^\/]+\/proxy\/[^\/]+\/aHR0cHM6Ly9leGFtcGxlLm9yZy9hLnBuZw\/a.png"/
end
test "inlined images using network-path ref are media proxied" do
ConfigMock |> stub_with(Pleroma.Test.StaticConfig)
clear_config([:media_proxy, :enabled], true)
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
content_type: "text/html",
status: "hii <img src=\"//example.org/a.png\" />"
})
activity = Repo.get(Activity, activity.id)
status = StatusView.render("show.json", activity: activity)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:content] =~
~r/^hii <img src="https?:\/\/[^\/]+\/proxy\/[^\/]+\/aHR0cHM6Ly9leGFtcGxlLm9yZy9hLnBuZw\/a.png"/
end
test "create mentions from the 'tag' field" do
recipient = insert(:user)
cc = insert_pair(:user) |> Enum.map(& &1.ap_id)
@ -597,6 +695,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
end
describe "attachments" do
test "skips meta for zero dimensions" do
for {width, height} <- [{0, 0}, {200, 0}, {0, 100}] do
object = %{
"type" => "Image",
"url" => [
%{
"mediaType" => "image/png",
"href" => "someurl",
"width" => width,
"height" => height
}
]
}
refute Map.has_key?(StatusView.render("attachment.json", %{attachment: object}), :meta)
end
end
test "Complete Mastodon style" do
object = %{
"type" => "Image",
@ -672,7 +788,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
test "put the url advertised in the Activity in to the url attribute" do
id = "https://wedistribute.org/wp-json/pterotype/v1/object/85810"
[activity] = Activity.search(nil, id)
[activity] = Activity.search(insert(:user), id, resolve: true)
status = StatusView.render("show.json", %{activity: activity})

View file

@ -252,22 +252,27 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
assert redirected_to(response, 301) == media_proxy_url
end
test "with `static` param and non-GIF image preview requested, " <>
"redirects to media preview proxy URI without `static` param",
test "with `static` param and non-GIF image preview requested, responds with a preview",
%{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
assert_dependencies_installed()
Tesla.Mock.mock(fn
%{method: :head, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
%{method: :get, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: File.read!("test/fixtures/image.jpg")}
end)
response = get(conn, url <> "?static=true")
assert response.status == 302
assert redirected_to(response) == url
assert response.status == 200
assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"]
assert response.resp_body != ""
end
test "with :min_content_length setting not matched by Content-Length header, " <>

View file

@ -45,6 +45,18 @@ defmodule Pleroma.Web.MediaProxyTest do
assert MediaProxy.url(local_root) == local_root
end
test "ignores data url" do
assert MediaProxy.url("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
assert MediaProxy.url("DATA:image/png;base64,AAAA") == "DATA:image/png;base64,AAAA"
end
test "preview_url ignores data url" do
clear_config([:media_preview_proxy, :enabled], true)
assert MediaProxy.preview_url("data:image/png;base64,AAAA") ==
"data:image/png;base64,AAAA"
end
test "encodes and decodes URL" do
url = "https://pleroma.soykaf.com/static/logo.png"
encoded = MediaProxy.url(url)
@ -59,6 +71,20 @@ defmodule Pleroma.Web.MediaProxyTest do
assert decode_result(encoded) == url
end
test "encodes and decodes a network-path reference URL as HTTPS" do
url = "//example.org/static/logo.png"
encoded = MediaProxy.url(url)
assert String.starts_with?(
encoded,
Config.get([:media_proxy, :base_url], Pleroma.Web.Endpoint.url())
)
assert String.ends_with?(encoded, "/logo.png")
assert decode_result(encoded) == "https:" <> url
end
test "encodes and decodes URL without a path" do
url = "https://pleroma.soykaf.com"
encoded = MediaProxy.url(url)

View file

@ -52,36 +52,60 @@ defmodule Pleroma.Web.OAuth.TokenControllerTest do
end
describe "GET /api/oauth_tokens" do
setup do
token = insert(:oauth_token) |> Repo.preload(:user)
%{token: token}
end
test "renders list", %{token: token} do
response =
build_conn()
|> assign(:user, token.user)
|> get("/api/oauth_tokens")
test "renders list" do
%{conn: conn} = oauth_access(["read:security"])
keys =
json_response(response, 200)
conn
|> get("/api/oauth_tokens")
|> json_response(200)
|> hd()
|> Map.keys()
assert Enum.sort(keys) == Enum.sort(["id", "app_name", "valid_until", "scopes"])
end
test "revoke token", %{token: token} do
response =
build_conn()
|> assign(:user, token.user)
|> delete("/api/oauth_tokens/#{token.id}")
test "is allowed with the read scope" do
%{conn: conn} = oauth_access(["read"])
tokens = Token.get_user_tokens(token.user)
assert [_] = conn |> get("/api/oauth_tokens") |> json_response(200)
end
assert tokens == []
assert response.status == 201
test "is forbidden without read:security" do
%{conn: conn} = oauth_access(["profile", "read:accounts", "write:security"])
assert %{"error" => "Insufficient permissions: read:security."} =
conn |> get("/api/oauth_tokens") |> json_response(403)
end
end
describe "DELETE /api/oauth_tokens/:id" do
test "revokes the token" do
%{conn: conn, user: user, token: token} = oauth_access(["write:security"])
other = insert(:oauth_token, user: user)
conn = delete(conn, "/api/oauth_tokens/#{other.id}")
assert conn.status == 201
assert [%Token{id: id}] = Token.get_user_tokens(user)
assert id == token.id
end
test "is allowed with the write scope" do
%{conn: conn, user: user} = oauth_access(["write"])
other = insert(:oauth_token, user: user)
assert delete(conn, "/api/oauth_tokens/#{other.id}").status == 201
end
test "is forbidden without write:security" do
%{conn: conn, user: user} = oauth_access(["profile", "read", "write:accounts"])
other = insert(:oauth_token, user: user)
assert %{"error" => "Insufficient permissions: write:security."} =
conn |> delete("/api/oauth_tokens/#{other.id}") |> json_response(403)
assert length(Token.get_user_tokens(user)) == 2
end
end
end

View file

@ -477,4 +477,53 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
|> get("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈")
|> json_response_and_validate_schema(404) == %{"error" => "Record not found"}
end
describe "rate limiting" do
test "PUT and DELETE share a per-status limit", %{conn: conn} do
clear_config([:rate_limit, :status_id_action], {60_000, 2})
user = insert(:user)
%{id: activity_id} = insert(:note_activity)
%{id: other_activity_id} = insert(:note_activity)
conn = prepare_conn_of_user(conn, user)
conn
|> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/☕")
|> json_response_and_validate_schema(200)
conn
|> delete("/api/v1/pleroma/statuses/#{activity_id}/reactions/☕")
|> json_response_and_validate_schema(200)
assert conn
|> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈")
|> json_response(429) == %{"error" => "Throttled"}
conn
|> put("/api/v1/pleroma/statuses/#{other_activity_id}/reactions/🐈")
|> json_response_and_validate_schema(200)
end
test "PUT and DELETE count against statuses_actions", %{conn: conn} do
clear_config([:rate_limit, :statuses_actions], {60_000, 2})
user = insert(:user)
[first, second, third] = insert_list(3, :note_activity)
conn = prepare_conn_of_user(conn, user)
conn
|> put("/api/v1/pleroma/statuses/#{first.id}/reactions/☕")
|> json_response_and_validate_schema(200)
conn
|> delete("/api/v1/pleroma/statuses/#{first.id}/reactions/☕")
|> json_response_and_validate_schema(200)
assert conn
|> put("/api/v1/pleroma/statuses/#{second.id}/reactions/☕")
|> json_response(429) == %{"error" => "Throttled"}
assert conn
|> delete("/api/v1/pleroma/statuses/#{third.id}/reactions/☕")
|> json_response(429) == %{"error" => "Throttled"}
end
end
end

View file

@ -0,0 +1,42 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugMRFTest do
# Not async: the MRF settings are global config.
use Pleroma.Web.ConnCase
alias Pleroma.StaticStubbedConfigMock, as: ConfigMock
alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock
alias Pleroma.Web.Plugs.HTTPSignaturePlug
import Mox
import Phoenix.Controller, only: [put_format: 2]
import Plug.Conn
setup do: clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
setup do: clear_config([:mrf_simple, :reject], [{"mastodon.example.org", "no reason"}])
defp signed_get(actor) do
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:instance, :rejected_instances] -> [] end)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
build_conn(:get, "/doesntmattter", %{"actor" => actor})
|> put_req_header("date", Pleroma.Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{actor}#main-key\",headers=\"(request-target) host date\",signature=\"fake\""
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
end
test "rejects signed requests from instances rejected by SimplePolicy" do
assert signed_get("http://mastodon.example.org/users/admin").halted
refute signed_get("http://allowed.example.org/users/admin").halted
end
end

View file

@ -0,0 +1,119 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugPolicyTest do
# Not async: changes global config and uses global HTTP mocks.
use Pleroma.Web.ConnCase
alias Pleroma.Keys
alias Pleroma.Object.Fetcher
alias Pleroma.Repo
alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.Publisher
alias Pleroma.Web.Plugs.DigestPlug
alias Pleroma.Web.Plugs.HTTPSignaturePlug
import ExUnit.CaptureLog
import Mox
import Phoenix.Controller, only: [put_format: 2]
import Pleroma.Factory
import Plug.Conn
defp incoming_conn(method, url, headers, body \\ "") do
%URI{host: host, path: path} = URI.parse(url)
headers = Enum.map(headers, fn {name, value} -> {String.downcase(name), value} end)
Plug.Test.conn(method, path, body)
|> Map.put(:req_headers, [{"host", host} | headers])
|> put_format("activity+json")
end
# Local users have no public_key column set; publish it like a remote copy would.
defp store_public_key(%User{} = user) do
{:ok, _, public_key} = Keys.keys_from_pem(user.keys)
entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
user
|> Ecto.Changeset.change(public_key: :public_key.pem_encode([entry]))
|> Repo.update!()
|> User.set_cache()
end
defp capture_request(method) do
test_pid = self()
Tesla.Mock.mock_global(fn %{method: ^method, headers: headers, body: body} = env ->
send(test_pid, {:request, headers, body})
%{env | status: 200, body: ""}
end)
end
test "accepts deliveries signed by Pleroma's publisher" do
stub_with(Pleroma.Web.ActivityPub.TransmogrifierMock, Pleroma.Web.ActivityPub.Transmogrifier)
capture_request(:post)
activity = insert(:note_activity)
store_public_key(User.get_by_ap_id(activity.data["actor"]))
inbox = "https://remote.example/inbox"
{:ok, _} =
%{inbox: inbox, activity_id: activity.id}
|> Publisher.prepare_one()
|> Publisher.publish_one()
assert_received {:request, headers, body}
{:ok, _, conn} =
incoming_conn(:post, inbox, [{"content-length", "#{byte_size(body)}"} | headers], body)
|> DigestPlug.read_body([])
conn = HTTPSignaturePlug.call(conn, %{})
assert conn.assigns.valid_signature == true
refute conn.halted
end
test "accepts object fetches signed by Pleroma's fetcher" do
clear_config([:activitypub, :sign_object_fetches], true)
capture_request(:get)
store_public_key(InternalFetchActor.get_actor())
url = "https://remote.example/objects/1"
Fetcher.fetch_and_contain_remote_object_from_id(url)
assert_received {:request, headers, _body}
conn =
incoming_conn(:get, url, headers)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
end
test "only logs policy violations when the policy is set to :log" do
clear_config([:activitypub, :signature_policy], :log)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
log =
capture_log(fn ->
conn =
incoming_conn(:post, "https://pleroma.example/inbox", [
{"date", Pleroma.Signature.signed_date()},
{"signature",
"keyId=\"https://remote.example/users/a#main-key\",headers=\"(request-target) host date\",signature=\"fake\""}
])
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
refute conn.halted
end)
assert log =~ "digest_not_signed"
end
end

View file

@ -13,6 +13,15 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
import Phoenix.Controller, only: [put_format: 2]
import Plug.Conn
defp put_signature(conn, actor, signed_headers \\ "(request-target) host date") do
conn
|> put_req_header("date", Pleroma.Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{actor}#main-key\",headers=\"#{signed_headers}\",signature=\"fake\""
)
end
test "it calls HTTPSignatures to check validity if the actor signed it" do
params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params)
@ -22,10 +31,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
@ -51,10 +57,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
@ -71,10 +74,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
@ -108,10 +108,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
conn
|> Map.put(:remote_ip, {192, 168, 0, 1})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.remote_ip == {192, 168, 0, 1}
@ -131,10 +128,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
@ -152,14 +146,84 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://allowed.example.org/users/admin#main-key"
)
|> put_signature("http://allowed.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
end
describe "signature policy" do
test "treats a signed GET with a stale date as unsigned" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600)
conn =
build_conn(:get, "/doesntmattter")
|> put_signature("http://mastodon.example.org/users/admin")
|> put_req_header("date", Pleroma.Signature.signed_date(stale))
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :date_too_old
refute conn.halted
end
test "marks a signed POST that does not cover the digest as invalid" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
conn =
build_conn(:post, "/inbox")
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :digest_not_signed
refute conn.halted
end
test "uses the digest computed from the body for signed POSTs" do
HTTPSignaturesMock
|> expect(:validate_conn, fn conn ->
get_req_header(conn, "digest") == ["SHA-256=computed"]
end)
conn =
build_conn(:post, "/inbox")
|> put_signature(
"http://mastodon.example.org/users/admin",
"(request-target) host date digest"
)
|> put_req_header("digest", "SHA-256=claimed")
|> assign(:digest, "SHA-256=computed")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
end
test "marks a signed POST whose body was not hashed as invalid" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
conn =
build_conn(:post, "/inbox")
|> put_signature(
"http://mastodon.example.org/users/admin",
"(request-target) host date digest"
)
|> put_req_header("digest", "SHA-256=claimed")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :body_not_hashed
end
end
end

View file

@ -50,7 +50,9 @@ defmodule Pleroma.Web.Plugs.RateLimiterTest do
test "it restricts based on config values" do
limiter_name = :test_plug_opts
scale = 80
# Long enough that the bucket cannot expire during the test on slow CI.
# The test expires it explicitly with expire_ttl/2.
scale = 60_000
limit = 5
clear_config([Pleroma.Web.Endpoint, :http, :ip], {127, 0, 0, 1})
@ -184,7 +186,8 @@ defmodule Pleroma.Web.Plugs.RateLimiterTest do
test "can have limits separate from unauthenticated connections" do
limiter_name = :test_authenticated1
scale = 50
# Long enough that the bucket cannot expire during the test on slow CI.
scale = 60_000
limit = 5
clear_config([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
clear_config([:rate_limit, limiter_name], [{1000, 1}, {scale, limit}])

View file

@ -0,0 +1,25 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.UserSocketTest do
use Pleroma.Web.ChannelCase
alias Pleroma.Web.UserSocket
import Pleroma.Factory
test "it connects with a valid token" do
user = insert(:user)
token = Phoenix.Token.sign(Pleroma.Web.Endpoint, "user socket", user.id)
assert {:ok, socket} = connect(UserSocket, %{"token" => token})
assert socket.assigns.user_name == user.nickname
end
# A crash here would put the secret key base into the error log.
test "it rejects tokens that are not strings" do
for token <- [["x"], 123, %{"a" => "b"}] do
assert :error = connect(UserSocket, %{"token" => token})
end
end
end

View file

@ -305,4 +305,30 @@ defmodule Pleroma.Web.WebFingerTest do
{:ok, _data} = WebFinger.finger("a@sub.mastodon.example")
end
end
describe "rate limiting" do
setup do
# the module setup stubs NullCache, the backoff needs a real cache
Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy)
test_pid = self()
Tesla.Mock.mock_global(fn %{url: url} ->
send(test_pid, {:request, url})
%Tesla.Env{status: 429, headers: [{"retry-after", "600"}]}
end)
end
test "backs off from a host that rate limits host-meta" do
assert {:error, :ratelimit} = WebFinger.finger("alice@ratelimited.example")
assert_received {:request, "https://ratelimited.example/.well-known/host-meta"}
refute_received {:request, _}
# a rate-limited host-meta lookup must not be cached as "no LRDD template"
assert {:ok, nil} = Cachex.get(:host_meta_cache, "ratelimited.example")
assert {:error, :ratelimit} = WebFinger.finger("bob@ratelimited.example")
assert {:error, :ratelimit} = WebFinger.find_lrdd_template("ratelimited.example")
refute_received {:request, _}
end
end
end

View file

@ -153,4 +153,38 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
assert {:cancel, :unreachable} = PublisherWorker.perform(job)
end
end
describe "Blocked instances:" do
defp publish_one_job(inbox, activity) do
%Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{"inbox" => inbox, "activity_id" => activity.id}
},
attempt: 1,
max_attempts: 5
}
end
test "cancels queued jobs to instances rejected by SimplePolicy" do
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
clear_config([:mrf_simple, :reject], [{"rejected.example", ""}])
{:ok, activity} = CommonAPI.post(insert(:user), %{status: "public"})
assert {:cancel, :rejected} =
PublisherWorker.perform(
publish_one_job("https://rejected.example/inbox", activity)
)
end
test "cancels queued private jobs to quarantined instances" do
clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}])
{:ok, activity} = CommonAPI.post(insert(:user), %{status: "hi", visibility: "private"})
assert {:cancel, :rejected} =
PublisherWorker.perform(
publish_one_job("https://quarantined.example/inbox", activity)
)
end
end
end

View file

@ -133,6 +133,28 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
end
end
describe "rate-limited fetches" do
setup do
Cachex.put(:http_backoff_cache, "ratelimited.example", true)
params =
insert(:note_activity).data
|> Map.put("actor", "https://ratelimited.example/users/alice")
%{args: %{"op" => "incoming_ap_doc", "params" => params}}
end
test "snooze while the actor's host is backed off", %{args: args} do
assert {:snooze, 300} = perform_job(ReceiverWorker, args)
end
test "stop snoozing two hours after the job was inserted", %{args: args} do
inserted_at = DateTime.add(DateTime.utc_now(), -121, :minute)
assert {:error, :ratelimit} = perform_job(ReceiverWorker, args, inserted_at: inserted_at)
end
end
test "cancels due to origin containment" do
params =
insert(:note_activity).data

View file

@ -13,6 +13,7 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
@unauthorized_object "https://unauthorized.example.com/"
@depth_object "https://depth.example.com/"
@content_type_object "https://bad_content_type.example.com/"
@ratelimited_object "https://ratelimited.example.com/"
describe "RemoteFetcherWorker" do
setup do
@ -37,6 +38,11 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
status: 200
}
%{method: :get, url: @ratelimited_object} ->
%Tesla.Env{
status: 429
}
%{method: :get, url: @content_type_object} ->
%Tesla.Env{
status: 200,
@ -73,6 +79,14 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
})
end
test "retries jobs for a rate-limited host" do
assert {:error, :ratelimit} =
perform_job(RemoteFetcherWorker, %{
"op" => "fetch_remote",
"id" => @ratelimited_object
})
end
test "does not retry jobs for when object returns wrong content type" do
assert {:cancel, _} =
perform_job(RemoteFetcherWorker, %{

View file

@ -306,6 +306,24 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do
refute Activity.get_by_ap_id(create["id"])
end
test "snoozes while the payload actor's host is backed off" do
Cachex.put(:http_backoff_cache, "ratelimited.example", true)
alice = build(:user, local: false, ap_id: "https://ratelimited.example/users/alice")
params = %{
"type" => "Create",
"actor" => alice.ap_id,
"id" => "https://ratelimited.example/activities/1",
"object" => "https://ratelimited.example/objects/1"
}
job = failed_signature_job(params, signature_headers_for(alice))
assert {:snooze, 300} = SignatureRetryWorker.perform(job)
old_job = %{job | inserted_at: DateTime.add(DateTime.utc_now(), -121, :minute)}
assert {:error, :ratelimit} = SignatureRetryWorker.perform(old_job)
end
test "processes the activity after refetching a valid matching signature" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")

View file

@ -0,0 +1,44 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Tests.ExifHelpers do
@moduledoc """
Helpers for upload filter tests that use exiftool as an independent oracle.
"""
@doc "Copies a fixture to a unique temporary file and returns an upload for it."
def upload_copy(fixture, content_type, attrs \\ []) do
source = Path.absname(Path.join("test/fixtures", fixture))
tempfile =
Path.join(
System.tmp_dir!(),
"pleroma_exif_#{System.unique_integer([:positive])}#{Path.extname(fixture)}"
)
File.cp!(source, tempfile)
ExUnit.Callbacks.on_exit(fn -> File.rm(tempfile) end)
struct!(
%Pleroma.Upload{
name: fixture,
content_type: content_type,
path: source,
tempfile: tempfile
},
attrs
)
end
@doc "Returns the GPS tags exiftool finds in a file, as text."
def gps_tags(path), do: exiftool!(["-gps:all", path])
@doc "Returns the value of one tag as exiftool reads it."
def tag(path, tag), do: exiftool!(["-s3", "-#{tag}", path])
defp exiftool!(args) do
{output, 0} = System.cmd("exiftool", ["-m" | args])
String.trim(output)
end
end

View file

@ -577,6 +577,16 @@ defmodule HttpRequestMock do
}}
end
# the same status via its canonical id
def get("http://mastodon.example.org/users/admin/statuses/99541947525187367", _, _, _) do
{:ok,
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/mastodon-note-object.json"),
headers: activitypub_object_headers()
}}
end
def get("http://mastodon.example.org/@admin/99541947525187368", _, _, _) do
{:ok,
%Tesla.Env{