diff --git a/docker/federation/Caddyfile b/docker/federation/Caddyfile new file mode 100644 index 0000000000..b109b0e9a9 --- /dev/null +++ b/docker/federation/Caddyfile @@ -0,0 +1,30 @@ +{ + auto_https disable_redirects +} + +http://pleroma1.test { + reverse_proxy pleroma1_web:4000 +} + +https://pleroma1.test { + tls internal + reverse_proxy pleroma1_web:4000 +} + +http://pleroma2.test { + reverse_proxy pleroma2_web:4000 +} + +https://pleroma2.test { + tls internal + reverse_proxy pleroma2_web:4000 +} + +http://mastodon.test { + reverse_proxy mastodon_web:3000 +} + +https://mastodon.test { + tls internal + reverse_proxy mastodon_web:3000 +} diff --git a/docker/federation/README.md b/docker/federation/README.md new file mode 100644 index 0000000000..b427be79a6 --- /dev/null +++ b/docker/federation/README.md @@ -0,0 +1,34 @@ +# Federation-in-a-box (Pleroma) + +This is a repo-local Docker Compose setup that runs **two Pleroma instances + Mastodon** in a private +container network and executes a small **federation smoke test suite**. + +This environment is intentionally **not production-like**: + +- It runs behind an internal Caddy reverse proxy (`gateway`) with **HTTP and internal TLS (HTTPS)**. +- It uses a private internal CA (from Caddy) and configures clients to trust it so federation can + happen over HTTPS inside the Docker network. + +## Usage + +From the Pleroma repo root: + +```bash +docker compose -f docker/federation/compose.yml up -d --build +docker compose -f docker/federation/compose.yml --profile fedtest run --rm fedtest +``` + +Cleanup: + +```bash +docker compose -f docker/federation/compose.yml down -v +``` + +## Notes + +- The test runner lives in `docker/federation/test_runner/` and is an **ExUnit** project using **Req**. +- Mastodon uses `${MASTODON_IMAGE}` (defaults to `ghcr.io/mastodon/mastodon:v4.5.3`). +- Pleroma is built from this repo via `../..` (the repo root). +- Default domains are `pleroma1.test`, `pleroma2.test`, `mastodon.test`. +- Seeded users are `alice@pleroma1.test`, `bob@pleroma2.test`, `carol@mastodon.test` with password `password`. +- The smoke tests cover follow, post delivery, favourites, boosts, and deletes across Pleroma↔Pleroma and Pleroma↔Mastodon. diff --git a/docker/federation/compose.yml b/docker/federation/compose.yml new file mode 100644 index 0000000000..8c742f8f23 --- /dev/null +++ b/docker/federation/compose.yml @@ -0,0 +1,433 @@ +services: + gateway: + image: caddy:2.8 + restart: unless-stopped + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_fed_data:/data/caddy + networks: + federation: + aliases: + - pleroma1.test + - pleroma2.test + - mastodon.test + + gateway_certs: + image: caddy:2.8 + restart: "no" + depends_on: + gateway: + condition: service_started + volumes: + - caddy_fed_data:/data/caddy + command: + - sh + - -lc + - | + set -eu + + while [ ! -f /data/caddy/pki/authorities/local/root.crt ]; do + sleep 1 + done + + chmod 755 /data/caddy/pki /data/caddy/pki/authorities /data/caddy/pki/authorities/local + chmod 644 /data/caddy/pki/authorities/local/root.crt + networks: + - federation + + pleroma1_db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: pleroma + POSTGRES_PASSWORD: pleroma + POSTGRES_DB: pleroma1 + volumes: + - pleroma1_fed_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pleroma"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - federation + + pleroma1_web: + build: + context: ../.. + image: pleroma-fedbox + restart: unless-stopped + depends_on: + pleroma1_db: + condition: service_healthy + environment: + DOMAIN: pleroma1.test + INSTANCE_NAME: Pleroma 1 (fedbox) + ADMIN_EMAIL: admin@pleroma1.test + NOTIFY_EMAIL: notify@pleroma1.test + DB_HOST: pleroma1_db + DB_PORT: 5432 + DB_NAME: pleroma1 + DB_USER: pleroma + DB_PASS: pleroma + FEDBOX_CACERTFILE: /caddy/pki/authorities/local/root.crt + SSL_CERT_FILE: /caddy/pki/authorities/local/root.crt + volumes: + - pleroma1_fed_data:/var/lib/pleroma + - ./pleroma/config.exs:/var/lib/pleroma/config.exs:ro + - caddy_fed_data:/caddy:ro + expose: + - "4000" + healthcheck: + test: + ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null || exit 1"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 30s + networks: + - federation + + pleroma1_seed: + image: pleroma-fedbox + restart: "no" + depends_on: + pleroma1_web: + condition: service_healthy + entrypoint: [] + environment: + DOMAIN: pleroma1.test + FEDBOX_SEED_USERS: "alice" + volumes: + - ./pleroma/config.exs:/var/lib/pleroma/config.exs:ro + command: + - sh + - -lc + - | + set -euo pipefail + + password="$${FEDBOX_SEED_PASSWORD:-password}" + + app=$$( + wget -qO- \ + --post-data "client_name=fedbox&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow&website=" \ + "http://pleroma1_web:4000/api/v1/apps" + ) + client_id=$$(echo "$$app" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p') + client_secret=$$(echo "$$app" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p') + + token=$$( + wget -qO- \ + --post-data "client_id=$$client_id&client_secret=$$client_secret&grant_type=client_credentials&scope=read+write+follow" \ + "http://pleroma1_web:4000/oauth/token" + ) + access_token=$$(echo "$$token" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + + for username in $${FEDBOX_SEED_USERS}; do + if wget -qO- "http://pleroma1_web:4000/.well-known/webfinger?resource=acct:$$username@$${DOMAIN}" >/dev/null; then + echo "[fedbox] pleroma1: $$username already exists" + continue + fi + + wget -qO- \ + --header "Authorization: Bearer $$access_token" \ + --header "Content-Type: application/json" \ + --post-data "{\"username\":\"$$username\",\"email\":\"$$username@$${DOMAIN}\",\"password\":\"$$password\",\"agreement\":true,\"locale\":\"en\"}" \ + "http://pleroma1_web:4000/api/v1/accounts" >/dev/null || true + + tries=0 + until wget -qO- "http://pleroma1_web:4000/.well-known/webfinger?resource=acct:$$username@$${DOMAIN}" >/dev/null; do + tries=$$((tries + 1)) + + if [ "$$tries" -ge 30 ]; then + echo "[fedbox] pleroma1: timeout waiting for webfinger $$username" >&2 + exit 1 + fi + + sleep 1 + done + done + networks: + - federation + + pleroma2_db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: pleroma + POSTGRES_PASSWORD: pleroma + POSTGRES_DB: pleroma2 + volumes: + - pleroma2_fed_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pleroma"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - federation + + pleroma2_web: + image: pleroma-fedbox + restart: unless-stopped + depends_on: + pleroma2_db: + condition: service_healthy + environment: + DOMAIN: pleroma2.test + INSTANCE_NAME: Pleroma 2 (fedbox) + ADMIN_EMAIL: admin@pleroma2.test + NOTIFY_EMAIL: notify@pleroma2.test + DB_HOST: pleroma2_db + DB_PORT: 5432 + DB_NAME: pleroma2 + DB_USER: pleroma + DB_PASS: pleroma + FEDBOX_CACERTFILE: /caddy/pki/authorities/local/root.crt + SSL_CERT_FILE: /caddy/pki/authorities/local/root.crt + volumes: + - pleroma2_fed_data:/var/lib/pleroma + - ./pleroma/config.exs:/var/lib/pleroma/config.exs:ro + - caddy_fed_data:/caddy:ro + expose: + - "4000" + healthcheck: + test: + ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null || exit 1"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 30s + networks: + - federation + + pleroma2_seed: + image: pleroma-fedbox + restart: "no" + depends_on: + pleroma2_web: + condition: service_healthy + entrypoint: [] + environment: + DOMAIN: pleroma2.test + FEDBOX_SEED_USERS: "bob" + volumes: + - ./pleroma/config.exs:/var/lib/pleroma/config.exs:ro + command: + - sh + - -lc + - | + set -euo pipefail + + password="$${FEDBOX_SEED_PASSWORD:-password}" + + app=$$( + wget -qO- \ + --post-data "client_name=fedbox&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow&website=" \ + "http://pleroma2_web:4000/api/v1/apps" + ) + client_id=$$(echo "$$app" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p') + client_secret=$$(echo "$$app" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p') + + token=$$( + wget -qO- \ + --post-data "client_id=$$client_id&client_secret=$$client_secret&grant_type=client_credentials&scope=read+write+follow" \ + "http://pleroma2_web:4000/oauth/token" + ) + access_token=$$(echo "$$token" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + + for username in $${FEDBOX_SEED_USERS}; do + if wget -qO- "http://pleroma2_web:4000/.well-known/webfinger?resource=acct:$$username@$${DOMAIN}" >/dev/null; then + echo "[fedbox] pleroma2: $$username already exists" + continue + fi + + wget -qO- \ + --header "Authorization: Bearer $$access_token" \ + --header "Content-Type: application/json" \ + --post-data "{\"username\":\"$$username\",\"email\":\"$$username@$${DOMAIN}\",\"password\":\"$$password\",\"agreement\":true,\"locale\":\"en\"}" \ + "http://pleroma2_web:4000/api/v1/accounts" >/dev/null || true + + tries=0 + until wget -qO- "http://pleroma2_web:4000/.well-known/webfinger?resource=acct:$$username@$${DOMAIN}" >/dev/null; do + tries=$$((tries + 1)) + + if [ "$$tries" -ge 30 ]; then + echo "[fedbox] pleroma2: timeout waiting for webfinger $$username" >&2 + exit 1 + fi + + sleep 1 + done + done + networks: + - federation + + mastodon_db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: mastodon + POSTGRES_PASSWORD: mastodon + POSTGRES_DB: mastodon + volumes: + - mastodon_fed_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mastodon"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - federation + + mastodon_redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - mastodon_fed_redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - federation + + mastodon_init: + image: ${MASTODON_IMAGE:-ghcr.io/mastodon/mastodon:v4.5.3} + restart: "no" + depends_on: + gateway_certs: + condition: service_completed_successfully + mastodon_db: + condition: service_healthy + mastodon_redis: + condition: service_healthy + environment: &mastodon_env + RAILS_ENV: production + NODE_ENV: production + PORT: 3000 + LOCAL_DOMAIN: mastodon.test + WEB_DOMAIN: mastodon.test + ANNOTATERB_SKIP_ON_DB_TASKS: "true" + REDIS_HOST: mastodon_redis + REDIS_PORT: 6379 + DB_HOST: mastodon_db + DB_PORT: 5432 + DB_NAME: mastodon + DB_USER: mastodon + DB_PASS: mastodon + # Production Mastodon blocks private-network federation by default. + # Allow common Docker/private ranges for federation-in-a-box. + ALLOWED_PRIVATE_ADDRESSES: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16" + SSL_CERT_FILE: /caddy/pki/authorities/local/root.crt + OTP_SECRET: "fedbox_otp_secret_mastodon" + SECRET_KEY_BASE: "fca7fa5fe8ca9b7bbcaa442535b973e772e6392f46aab7fb3ec227ef5eb8d8c6605b921f7af4c2cc41f19a20633334e11c6012d6de958d0b14c4c2aa24a294ab" + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "ZVdBBUYdAX9il2dZ3mwVRv1h7bDnRlH9oybCdHautQUNIhSBoV7wdpKm+ByScMaeEChmrmxIhIMBujlnikHUqA==" + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "82yPOOdC5iAbaCU7ck0hsWP1kJqMH8g7v/vtzS+AlWBFsVvcuzGYBg888Oa+vBkhXY8Xr1jE03WbSwVwHDR3Aw==" + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "6UZS4kC1TsljaOCidkEGQHqnIIcN0zqayJu2ANKYAHsLtKnjZOvcEvhDehRaf0LfQNHim/9asXmILvVukOuw0w==" + command: + - sh + - -lc + - | + set -euo pipefail + + bundle exec rails db:prepare + + # Create an initial user for API-driven smoke tests. + bin/tootctl accounts create carol --email carol@mastodon.test --confirmed --approve --role Owner || true + + # Ensure a stable password for the OAuth authorization code flow. + bundle exec rails runner 'u = User.find_by(email: "carol@mastodon.test"); raise "carol missing" unless u; u.mark_email_as_confirmed! unless u.confirmed?; u.approve! unless u.approved?; u.change_password!("password");' + volumes: + - mastodon_fed_system:/mastodon/public/system + - ./mastodon/initializers/00_letter_opener_web_stub.rb:/opt/mastodon/config/initializers/00_letter_opener_web_stub.rb:ro + - ./mastodon/initializers/01_fedbox_settings.rb:/opt/mastodon/config/initializers/01_fedbox_settings.rb:ro + - caddy_fed_data:/caddy:ro + networks: + - federation + + mastodon_web: + image: ${MASTODON_IMAGE:-ghcr.io/mastodon/mastodon:v4.5.3} + restart: unless-stopped + depends_on: + mastodon_init: + condition: service_completed_successfully + environment: *mastodon_env + command: bundle exec puma -C config/puma.rb + expose: + - "3000" + volumes: + - mastodon_fed_system:/mastodon/public/system + - ./mastodon/initializers/00_letter_opener_web_stub.rb:/opt/mastodon/config/initializers/00_letter_opener_web_stub.rb:ro + - ./mastodon/initializers/01_fedbox_settings.rb:/opt/mastodon/config/initializers/01_fedbox_settings.rb:ro + - caddy_fed_data:/caddy:ro + healthcheck: + test: ["CMD-SHELL", "curl -s --noproxy localhost localhost:3000/health | grep -q 'OK' || exit 1"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 30s + networks: + - federation + + mastodon_sidekiq: + image: ${MASTODON_IMAGE:-ghcr.io/mastodon/mastodon:v4.5.3} + restart: unless-stopped + depends_on: + mastodon_init: + condition: service_completed_successfully + environment: *mastodon_env + command: bundle exec sidekiq + volumes: + - mastodon_fed_system:/mastodon/public/system + - ./mastodon/initializers/00_letter_opener_web_stub.rb:/opt/mastodon/config/initializers/00_letter_opener_web_stub.rb:ro + - ./mastodon/initializers/01_fedbox_settings.rb:/opt/mastodon/config/initializers/01_fedbox_settings.rb:ro + - caddy_fed_data:/caddy:ro + networks: + - federation + + fedtest: + profiles: ["fedtest"] + build: + context: ./test_runner + image: pleroma-fedbox-test-runner + depends_on: + gateway_certs: + condition: service_completed_successfully + pleroma1_seed: + condition: service_completed_successfully + pleroma2_seed: + condition: service_completed_successfully + pleroma1_web: + condition: service_healthy + pleroma2_web: + condition: service_healthy + mastodon_web: + condition: service_healthy + mastodon_sidekiq: + condition: service_started + environment: + FEDTEST_PLEROMA1_HANDLE: "@alice@pleroma1.test" + FEDTEST_PLEROMA2_HANDLE: "@bob@pleroma2.test" + FEDTEST_MASTODON_HANDLE: "@carol@mastodon.test" + FEDTEST_PASSWORD: "password" + FEDTEST_SCHEME: https + FEDTEST_CACERTFILE: /caddy/pki/authorities/local/root.crt + networks: + - federation + volumes: + - caddy_fed_data:/caddy:ro + +volumes: + caddy_fed_data: + pleroma1_fed_db: + pleroma1_fed_data: + pleroma2_fed_db: + pleroma2_fed_data: + mastodon_fed_db: + mastodon_fed_redis: + mastodon_fed_system: + +networks: + federation: diff --git a/docker/federation/mastodon/initializers/00_letter_opener_web_stub.rb b/docker/federation/mastodon/initializers/00_letter_opener_web_stub.rb new file mode 100644 index 0000000000..e7d399f8b9 --- /dev/null +++ b/docker/federation/mastodon/initializers/00_letter_opener_web_stub.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# The official Mastodon docker image installs production-only gems. +# When running Mastodon in development mode (needed for HTTP in fedbox), +# some initializers reference LetterOpenerWeb, which isn't present. +# +# This stub keeps Mastodon bootable for federation smoke tests. +module LetterOpenerWeb + # Used in config/routes.rb in development mode: + # mount LetterOpenerWeb::Engine, at: "/letter_opener" + # For fedbox we don't need the UI, but routes need this constant to exist. + class Engine + def self.call(_env) + [404, {"content-type" => "text/plain"}, ["Not Found"]] + end + end + + class LettersController + def self.content_security_policy(&_block) + end + + def self.after_action(&_block) + end + end +end diff --git a/docker/federation/mastodon/initializers/01_fedbox_settings.rb b/docker/federation/mastodon/initializers/01_fedbox_settings.rb new file mode 100644 index 0000000000..90959daef7 --- /dev/null +++ b/docker/federation/mastodon/initializers/01_fedbox_settings.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# The official Mastodon docker image runs as a non-root user, and `/opt/mastodon/db` +# is not writable in the image. Rails defaults to dumping `db/schema.rb` after +# migrations, but for the federation-in-a-box smoke tests we don't need schema +# dumps, so disable them to keep `rails db:prepare` working. + +Rails.application.config.active_record.dump_schema_after_migration = false + +if defined?(ActiveRecord) + ActiveRecord.dump_schema_after_migration = false +end diff --git a/docker/federation/pleroma/config.exs b/docker/federation/pleroma/config.exs new file mode 100644 index 0000000000..7747fb1f46 --- /dev/null +++ b/docker/federation/pleroma/config.exs @@ -0,0 +1,28 @@ +import Config + +# Advertise the instance as HTTPS via the Caddy gateway. +config :pleroma, Pleroma.Web.Endpoint, + url: [host: System.get_env("DOMAIN", "pleroma.test"), scheme: "https", port: 443] + +# Trust the fedbox Caddy internal CA so federation over HTTPS works inside the +# docker network (Pleroma <-> Pleroma <-> Mastodon). +cacertfile = System.get_env("FEDBOX_CACERTFILE", "/caddy/pki/authorities/local/root.crt") + +config :pleroma, :http, + adapter: [ + ssl_options: [ + verify: :verify_peer, + cacertfile: cacertfile, + depth: 20, + reuse_sessions: false, + log_level: :warning, + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + ] + +# Keep it permissive for local testing. +config :pleroma, :instance, + registrations_open: true + +# Disable CAPTCHA for fedbox user seeding / smoke tests. +config :pleroma, Pleroma.Captcha, enabled: false diff --git a/docker/federation/test_runner/.gitignore b/docker/federation/test_runner/.gitignore new file mode 100644 index 0000000000..44bd7262b2 --- /dev/null +++ b/docker/federation/test_runner/.gitignore @@ -0,0 +1,2 @@ +/_build +/deps diff --git a/docker/federation/test_runner/Dockerfile b/docker/federation/test_runner/Dockerfile new file mode 100644 index 0000000000..5295acf73d --- /dev/null +++ b/docker/federation/test_runner/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 + +ARG ELIXIR_IMAGE=elixir:1.19.0-otp-28 + +FROM ${ELIXIR_IMAGE} + +WORKDIR /app + +ENV MIX_ENV=test + +RUN mix local.hex --force && mix local.rebar --force + +COPY mix.exs mix.lock ./ + +RUN mix deps.get --only test +RUN mix deps.compile + +COPY test test + +CMD ["mix", "test", "--color", "--trace"] diff --git a/docker/federation/test_runner/mix.exs b/docker/federation/test_runner/mix.exs new file mode 100644 index 0000000000..58ccbdce4f --- /dev/null +++ b/docker/federation/test_runner/mix.exs @@ -0,0 +1,26 @@ +defmodule PleromaFedboxTestRunner.MixProject do + use Mix.Project + + def project do + [ + app: :pleroma_fedbox_test_runner, + version: "0.1.0", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:jason, "1.4.4"}, + {:req, "0.5.16"} + ] + end +end diff --git a/docker/federation/test_runner/mix.lock b/docker/federation/test_runner/mix.lock new file mode 100644 index 0000000000..2e43e090e7 --- /dev/null +++ b/docker/federation/test_runner/mix.lock @@ -0,0 +1,11 @@ +%{ + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, +} diff --git a/docker/federation/test_runner/test/federation_box_test.exs b/docker/federation/test_runner/test/federation_box_test.exs new file mode 100644 index 0000000000..5987b73033 --- /dev/null +++ b/docker/federation/test_runner/test/federation_box_test.exs @@ -0,0 +1,1511 @@ +defmodule FederationBoxTest do + use ExUnit.Case, async: false + + @moduletag timeout: 240_000 + + @poll_interval_ms 1_000 + + setup_all do + scheme = fedtest_scheme() + password = System.get_env("FEDTEST_PASSWORD", "password") + + alice_handle = System.get_env("FEDTEST_PLEROMA1_HANDLE", "@alice@pleroma1.test") + bob_handle = System.get_env("FEDTEST_PLEROMA2_HANDLE", "@bob@pleroma2.test") + carol_handle = System.get_env("FEDTEST_MASTODON_HANDLE", "@carol@mastodon.test") + + %{username: alice_username, domain: alice_domain} = parse_handle!(alice_handle) + %{username: bob_username, domain: bob_domain} = parse_handle!(bob_handle) + %{username: carol_username, domain: carol_domain} = parse_handle!(carol_handle) + + pleroma1_base_url = "#{scheme}://#{alice_domain}" + pleroma2_base_url = "#{scheme}://#{bob_domain}" + mastodon_base_url = "#{scheme}://#{carol_domain}" + + scopes = "read write follow" + redirect_uri = "urn:ietf:wg:oauth:2.0:oob" + + alice_actor_id = + wait_until!( + fn -> webfinger_self_href(alice_username, alice_domain) end, + "webfinger ready #{alice_handle}" + ) + + bob_actor_id = + wait_until!( + fn -> webfinger_self_href(bob_username, bob_domain) end, + "webfinger ready #{bob_handle}" + ) + + carol_actor_id = + wait_until!( + fn -> webfinger_self_href(carol_username, carol_domain) end, + "webfinger ready #{carol_handle}" + ) + + alice_actor_id_variants = actor_id_variants(alice_actor_id) + bob_actor_id_variants = actor_id_variants(bob_actor_id) + carol_actor_id_variants = actor_id_variants(carol_actor_id) + + %{client_id: alice_client_id, client_secret: alice_client_secret} = + create_oauth_app!(pleroma1_base_url, scopes) + + alice_access_token = + password_grant_token!( + pleroma1_base_url, + alice_client_id, + alice_client_secret, + [alice_username, "#{alice_username}@#{alice_domain}"], + password, + scopes + ) + + %{client_id: bob_client_id, client_secret: bob_client_secret} = + create_oauth_app!(pleroma2_base_url, scopes) + + bob_access_token = + password_grant_token!( + pleroma2_base_url, + bob_client_id, + bob_client_secret, + [bob_username, "#{bob_username}@#{bob_domain}"], + password, + scopes + ) + + %{client_id: carol_client_id, client_secret: carol_client_secret} = + create_oauth_app!(mastodon_base_url, scopes) + + carol_session = mastodon_sign_in!(mastodon_base_url, "#{carol_username}@#{carol_domain}", password) + + carol_auth_code = + authorize_rails_oauth_app!( + mastodon_base_url, + carol_session, + carol_client_id, + redirect_uri, + scopes + ) + + carol_access_token = + exchange_auth_code!( + mastodon_base_url, + carol_client_id, + carol_client_secret, + redirect_uri, + carol_auth_code + ) + + {:ok, + %{ + pleroma1_base_url: pleroma1_base_url, + pleroma2_base_url: pleroma2_base_url, + mastodon_base_url: mastodon_base_url, + alice_handle: alice_handle, + bob_handle: bob_handle, + carol_handle: carol_handle, + alice_access_token: alice_access_token, + bob_access_token: bob_access_token, + carol_access_token: carol_access_token, + alice_actor_id: alice_actor_id, + bob_actor_id: bob_actor_id, + alice_actor_id_variants: alice_actor_id_variants, + bob_actor_id_variants: bob_actor_id_variants, + carol_actor_id_variants: carol_actor_id_variants + }} + end + + test "outgoing follows are accepted (pleroma + mastodon)", ctx do + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.bob_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.carol_handle, + ctx.alice_actor_id_variants + ) + end + + test "pleroma1 receives posts from followed accounts (pleroma + mastodon)", ctx do + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.bob_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.carol_handle, + ctx.alice_actor_id_variants + ) + + unique = unique_token() + bob_text = "fedbox: hello from bob #{unique}" + carol_text = "fedbox: hello from carol #{unique}" + + _ = create_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_text) + _ = create_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_text) + + wait_until!( + fn -> home_timeline_contains?(ctx.pleroma1_base_url, ctx.alice_access_token, bob_text) end, + "pleroma1 received pleroma2 post" + ) + + wait_until!( + fn -> home_timeline_contains?(ctx.pleroma1_base_url, ctx.alice_access_token, carol_text) end, + "pleroma1 received mastodon post" + ) + end + + test "remote receives our posts (pleroma + mastodon)", ctx do + follow_and_assert_local_accept!( + ctx.pleroma2_base_url, + ctx.bob_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.bob_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.mastodon_base_url, + ctx.carol_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.carol_actor_id_variants + ) + + unique = unique_token() + alice_text = "fedbox: hello from alice #{unique}" + + _alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + + wait_until!( + fn -> home_timeline_contains?(ctx.pleroma2_base_url, ctx.bob_access_token, alice_text) end, + "pleroma2 received alice post" + ) + + wait_until!( + fn -> home_timeline_contains?(ctx.mastodon_base_url, ctx.carol_access_token, alice_text) end, + "mastodon received alice post" + ) + end + + test "likes: roundtrip with pleroma", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.bob_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.pleroma2_base_url, + ctx.bob_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.bob_actor_id_variants + ) + + alice_text = "fedbox: like me (pleroma) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + bob_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma2_base_url, ctx.bob_access_token, alice_uri_variants) + end, + "pleroma2 received alice post" + ) + + favourite_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_status["id"]) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + favourites_count = Map.get(status, "favourites_count", 0) + is_integer(favourites_count) and favourites_count > 0 + end, + "pleroma1 received bob like" + ) + + bob_text = "fedbox: like this (pleroma) #{unique}" + bob_status = create_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_text) + bob_status_id = bob_status["id"] + + bob_uri_variants = + bob_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_bob_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma1_base_url, ctx.alice_access_token, bob_uri_variants) + end, + "pleroma1 received bob post" + ) + + favourite_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_view_of_bob_status["id"]) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_status_id) + favourites_count = Map.get(status, "favourites_count", 0) + is_integer(favourites_count) and favourites_count > 0 + end, + "pleroma2 received alice like" + ) + end + + test "likes: roundtrip with mastodon", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.carol_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.mastodon_base_url, + ctx.carol_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.carol_actor_id_variants + ) + + alice_text = "fedbox: like me (mastodon) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + mastodon_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.mastodon_base_url, + ctx.carol_access_token, + alice_uri_variants + ) + end, + "mastodon received alice post" + ) + + favourite_status!(ctx.mastodon_base_url, ctx.carol_access_token, mastodon_status["id"]) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + favourites_count = Map.get(status, "favourites_count", 0) + is_integer(favourites_count) and favourites_count > 0 + end, + "pleroma1 received mastodon like" + ) + + carol_text = "fedbox: like this (mastodon) #{unique}" + carol_status = create_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_text) + carol_status_id = carol_status["id"] + + carol_uri_variants = + carol_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_carol_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.pleroma1_base_url, + ctx.alice_access_token, + carol_uri_variants + ) + end, + "pleroma1 received carol post" + ) + + favourite_status!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + alice_view_of_carol_status["id"] + ) + + wait_until!( + fn -> + status = fetch_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_status_id) + favourites_count = Map.get(status, "favourites_count", 0) + is_integer(favourites_count) and favourites_count > 0 + end, + "mastodon received alice like" + ) + end + + test "boosts: roundtrip with pleroma", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.bob_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.pleroma2_base_url, + ctx.bob_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.bob_actor_id_variants + ) + + alice_text = "fedbox: boost me (pleroma) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + bob_view_of_alice_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma2_base_url, ctx.bob_access_token, alice_uri_variants) + end, + "pleroma2 received alice post" + ) + + _reblog = reblog_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_view_of_alice_status["id"]) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + reblogs_count = Map.get(status, "reblogs_count", 0) + is_integer(reblogs_count) and reblogs_count > 0 + end, + "pleroma1 received bob boost" + ) + + bob_text = "fedbox: boost this (pleroma) #{unique}" + bob_status = create_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_text) + bob_status_id = bob_status["id"] + + bob_uri_variants = + bob_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_bob_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma1_base_url, ctx.alice_access_token, bob_uri_variants) + end, + "pleroma1 received bob post" + ) + + _reblog = reblog_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_view_of_bob_status["id"]) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_status_id) + reblogs_count = Map.get(status, "reblogs_count", 0) + is_integer(reblogs_count) and reblogs_count > 0 + end, + "pleroma2 received alice boost" + ) + end + + test "boosts: roundtrip with mastodon", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.carol_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.mastodon_base_url, + ctx.carol_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.carol_actor_id_variants + ) + + alice_text = "fedbox: boost me (mastodon) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + mastodon_view_of_alice_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.mastodon_base_url, + ctx.carol_access_token, + alice_uri_variants + ) + end, + "mastodon received alice post" + ) + + _reblog = + reblog_status!( + ctx.mastodon_base_url, + ctx.carol_access_token, + mastodon_view_of_alice_status["id"] + ) + + wait_until!( + fn -> + status = fetch_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + reblogs_count = Map.get(status, "reblogs_count", 0) + is_integer(reblogs_count) and reblogs_count > 0 + end, + "pleroma1 received mastodon boost" + ) + + carol_text = "fedbox: boost this (mastodon) #{unique}" + carol_status = create_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_text) + carol_status_id = carol_status["id"] + + carol_uri_variants = + carol_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_carol_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.pleroma1_base_url, + ctx.alice_access_token, + carol_uri_variants + ) + end, + "pleroma1 received carol post" + ) + + _reblog = + reblog_status!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + alice_view_of_carol_status["id"] + ) + + wait_until!( + fn -> + status = fetch_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_status_id) + reblogs_count = Map.get(status, "reblogs_count", 0) + is_integer(reblogs_count) and reblogs_count > 0 + end, + "mastodon received alice boost" + ) + end + + test "deletes: roundtrip with pleroma", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.bob_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.pleroma2_base_url, + ctx.bob_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.bob_actor_id_variants + ) + + alice_text = "fedbox: delete me (to pleroma) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + bob_view_of_alice_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma2_base_url, ctx.bob_access_token, alice_uri_variants) + end, + "pleroma2 received alice post" + ) + + _ = delete_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + + wait_until!( + fn -> status_gone?(ctx.pleroma2_base_url, ctx.bob_access_token, bob_view_of_alice_status["id"]) end, + "pleroma2 removed deleted alice post" + ) + + bob_text = "fedbox: delete me (from pleroma) #{unique}" + bob_status = create_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_text) + bob_status_id = bob_status["id"] + + bob_uri_variants = + bob_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_bob_status = + wait_until!( + fn -> + find_status_by_uri_variant(ctx.pleroma1_base_url, ctx.alice_access_token, bob_uri_variants) + end, + "pleroma1 received bob post" + ) + + _ = delete_status!(ctx.pleroma2_base_url, ctx.bob_access_token, bob_status_id) + + wait_until!( + fn -> status_gone?(ctx.pleroma1_base_url, ctx.alice_access_token, alice_view_of_bob_status["id"]) end, + "pleroma1 removed deleted bob post" + ) + end + + test "deletes: roundtrip with mastodon", ctx do + unique = unique_token() + + follow_and_assert_remote_accept!( + ctx.pleroma1_base_url, + ctx.alice_access_token, + ctx.carol_handle, + ctx.alice_actor_id_variants + ) + + follow_and_assert_local_accept!( + ctx.mastodon_base_url, + ctx.carol_access_token, + ctx.alice_handle, + ctx.alice_actor_id, + ctx.carol_actor_id_variants + ) + + alice_text = "fedbox: delete me (to mastodon) #{unique}" + alice_status = create_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_text) + alice_status_id = alice_status["id"] + + alice_uri_variants = + alice_status + |> status_uri() + |> actor_id_variants() + + mastodon_view_of_alice_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.mastodon_base_url, + ctx.carol_access_token, + alice_uri_variants + ) + end, + "mastodon received alice post" + ) + + _ = delete_status!(ctx.pleroma1_base_url, ctx.alice_access_token, alice_status_id) + + wait_until!( + fn -> + status_gone?( + ctx.mastodon_base_url, + ctx.carol_access_token, + mastodon_view_of_alice_status["id"] + ) + end, + "mastodon removed deleted alice post" + ) + + carol_text = "fedbox: delete me (from mastodon) #{unique}" + carol_status = create_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_text) + carol_status_id = carol_status["id"] + + carol_uri_variants = + carol_status + |> status_uri() + |> actor_id_variants() + + alice_view_of_carol_status = + wait_until!( + fn -> + find_status_by_uri_variant( + ctx.pleroma1_base_url, + ctx.alice_access_token, + carol_uri_variants + ) + end, + "pleroma1 received carol post" + ) + + _ = delete_status!(ctx.mastodon_base_url, ctx.carol_access_token, carol_status_id) + + wait_until!( + fn -> + status_gone?( + ctx.pleroma1_base_url, + ctx.alice_access_token, + alice_view_of_carol_status["id"] + ) + end, + "pleroma1 removed deleted carol post" + ) + end + + defp follow_and_assert_remote_accept!(base_url, access_token, handle, local_actor_id_variants) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) and + is_list(local_actor_id_variants) do + _ = follow_remote!(base_url, access_token, handle) + + %{username: username, domain: domain} = parse_handle!(handle) + + remote_actor_id = + wait_until!( + fn -> webfinger_self_href(username, domain) end, + "actor #{handle}" + ) + + wait_until!( + fn -> + followers = fetch_follower_ids(remote_actor_id) + Enum.any?(followers, &(&1 in local_actor_id_variants)) + end, + "follow accepted local -> #{handle}" + ) + + :ok + end + + defp follow_and_assert_local_accept!( + remote_base_url, + remote_access_token, + local_handle, + local_actor_id, + remote_actor_id_variants + ) + when is_binary(remote_base_url) and is_binary(remote_access_token) and + is_binary(local_handle) and + is_binary(local_actor_id) and is_list(remote_actor_id_variants) do + _ = follow_remote!(remote_base_url, remote_access_token, local_handle) + + wait_until!( + fn -> + followers = fetch_follower_ids(local_actor_id) + Enum.any?(followers, &(&1 in remote_actor_id_variants)) + end, + "follow accepted remote -> #{local_handle}" + ) + + :ok + end + + defp unique_token do + System.unique_integer([:positive]) + |> Integer.to_string() + end + + defp create_status!(base_url, access_token, text) + when is_binary(base_url) and is_binary(access_token) and is_binary(text) do + resp = + req_post!( + base_url <> "/api/v1/statuses", + headers: [{"authorization", "Bearer " <> access_token}], + form: [{"status", text}, {"visibility", "public"}] + ) + + ensure_json!(resp.body) + end + + defp fetch_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_get!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + ensure_json!(resp.body) + end + + defp fetch_home_timeline!(base_url, access_token, opts) + when is_binary(base_url) and is_binary(access_token) and is_list(opts) do + limit = Keyword.get(opts, :limit, 20) + + resp = + req_get!( + base_url <> "/api/v1/timelines/home", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"limit", limit}] + ) + + ensure_json!(resp.body) + end + + defp home_timeline_contains?(base_url, access_token, needle) + when is_binary(base_url) and is_binary(access_token) and is_binary(needle) do + statuses = fetch_home_timeline!(base_url, access_token, limit: 40) + + Enum.any?(statuses, fn status -> + content = Map.get(status, "content", "") + is_binary(content) and String.contains?(content, needle) + end) + end + + defp favourite_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + _resp = + req_post!( + base_url <> "/api/v1/statuses/" <> status_id <> "/favourite", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + :ok + end + + defp reblog_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_post!( + base_url <> "/api/v1/statuses/" <> status_id <> "/reblog", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + ensure_json!(resp.body) + end + + defp delete_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_delete!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + if resp.status in 200..299 do + :ok + else + raise("unexpected status when deleting #{status_id}: #{resp.status}") + end + end + + defp status_gone?(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_get!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + resp.status in [404, 410] + end + + defp find_status_by_uri_variant(base_url, access_token, uri_variants) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do + statuses = fetch_home_timeline!(base_url, access_token, limit: 40) + + status = + Enum.find(statuses, fn status -> + status + |> status_uri() + |> case do + uri when is_binary(uri) -> uri in uri_variants + _ -> false + end + end) + + cond do + is_map(status) -> + {:ok, status} + + true -> + find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) + end + end + + defp find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do + find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v2) || + find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v1) + end + + defp find_status_by_uri_variant_via_search_endpoint( + base_url, + access_token, + uri_variants, + version + ) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) and + version in [:v1, :v2] do + endpoint = + case version do + :v2 -> "/api/v2/search" + :v1 -> "/api/v1/search" + end + + Enum.find_value(uri_variants, fn query -> + params = + case version do + :v2 -> + [{"q", query}, {"type", "statuses"}, {"resolve", "true"}, {"limit", 10}] + + :v1 -> + [{"q", query}, {"resolve", "true"}, {"limit", 10}] + end + + resp = + req_get!( + base_url <> endpoint, + headers: [{"authorization", "Bearer " <> access_token}], + params: params + ) + + cond do + resp.status in 200..299 -> + body = ensure_json!(resp.body) + statuses = Map.get(body, "statuses", []) + + Enum.find_value(statuses, fn status -> + case status_uri(status) do + uri when is_binary(uri) -> + if Enum.member?(uri_variants, uri) do + {:ok, status} + else + nil + end + + _ -> + nil + end + end) + + true -> + nil + end + end) + end + + defp status_uri(%{} = status) do + Map.get(status, "uri") || Map.get(status, "url") + end + + defp create_oauth_app!(base_url, scopes) when is_binary(base_url) and is_binary(scopes) do + resp = + req_post!( + base_url <> "/api/v1/apps", + form: [ + {"client_name", "fedbox"}, + {"redirect_uris", "urn:ietf:wg:oauth:2.0:oob"}, + {"scopes", scopes}, + {"website", ""} + ] + ) + + body = ensure_json!(resp.body) + + %{ + client_id: Map.fetch!(body, "client_id"), + client_secret: Map.fetch!(body, "client_secret") + } + end + + defp authorize_rails_oauth_app!(base_url, %{cookie_jar: jar}, client_id, redirect_uri, scopes) + when is_binary(base_url) and is_binary(client_id) and is_binary(redirect_uri) and + is_binary(scopes) do + query = + URI.encode_query(%{ + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "response_type" => "code", + "scope" => scopes, + "state" => "" + }) + + {html, jar} = get_html!(base_url, "/oauth/authorize?" <> query, jar) + csrf_token = extract_csrf_token!(html) + + resp = + req_post!( + base_url <> "/oauth/authorize", + headers: cookie_headers(jar), + form: [ + {"authenticity_token", csrf_token}, + {"client_id", client_id}, + {"redirect_uri", redirect_uri}, + {"code_challenge", ""}, + {"code_challenge_method", ""}, + {"response_type", "code"}, + {"scope", scopes}, + {"state", ""} + ] + ) + + _jar = update_cookie_jar(jar, resp) + + cond do + resp.status in 300..399 -> + resp + |> Req.Response.get_header("location") + |> List.first() + |> case do + location when is_binary(location) and location != "" -> + location + |> URI.parse() + |> Map.get(:query, "") + |> URI.decode_query() + |> Map.get("code") + + _ -> + raise("oauth code not found") + end + + resp.status in 200..299 -> + extract_oauth_code!(to_string(resp.body)) + + true -> + raise("unexpected oauth authorize response: #{resp.status}") + end + end + + defp exchange_auth_code!(base_url, client_id, client_secret, redirect_uri, code) + when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and + is_binary(redirect_uri) and is_binary(code) do + resp = + req_post!( + base_url <> "/oauth/token", + form: [ + {"grant_type", "authorization_code"}, + {"code", code}, + {"client_id", client_id}, + {"client_secret", client_secret}, + {"redirect_uri", redirect_uri} + ] + ) + + body = ensure_json!(resp.body) + Map.fetch!(body, "access_token") + end + + defp password_grant_token!( + base_url, + client_id, + client_secret, + usernames, + password, + scopes + ) + when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and + is_list(usernames) and is_binary(password) and is_binary(scopes) do + usernames + |> Enum.find_value(fn username -> + resp = + req_post!( + base_url <> "/oauth/token", + form: [ + {"grant_type", "password"}, + {"username", username}, + {"password", password}, + {"client_id", client_id}, + {"client_secret", client_secret}, + {"scope", scopes} + ] + ) + + if resp.status in 200..299 do + body = ensure_json!(resp.body) + Map.fetch!(body, "access_token") + else + nil + end + end) + |> case do + token when is_binary(token) and token != "" -> + token + + _ -> + raise("failed to get password-grant token from #{base_url}") + end + end + + defp mastodon_sign_in!(base_url, email, password) + when is_binary(base_url) and is_binary(email) and is_binary(password) do + {html, jar} = get_html!(base_url, "/auth/sign_in", %{}) + csrf_token = extract_csrf_token!(html) + + {_, jar} = + post_rails_form!(base_url, "/auth/sign_in", jar, csrf_token, [ + {"user[email]", email}, + {"user[password]", password} + ]) + + %{cookie_jar: jar} + end + + defp webfinger_self_href(username, domain) when is_binary(username) and is_binary(domain) do + base_url = "#{fedtest_scheme()}://#{domain}" + + resp = + req_get!( + base_url <> "/.well-known/webfinger", + headers: [{"accept", "application/jrd+json"}], + params: [{"resource", "acct:#{username}@#{domain}"}] + ) + + cond do + resp.status in 200..299 -> + with {:ok, body} <- decode_json(resp.body), + links when is_list(links) <- Map.get(body, "links"), + %{} = link <- Enum.find(links, &(&1["rel"] == "self")), + href when is_binary(href) and href != "" <- link["href"] do + {:ok, href} + else + _ -> nil + end + + resp.status in 300..399 -> + case Req.Response.get_header(resp, "location") do + [location | _] -> + resp = + req_get!( + location, + headers: [{"accept", "application/jrd+json"}] + ) + + if resp.status in 200..299 do + with {:ok, body} <- decode_json(resp.body), + links when is_list(links) <- Map.get(body, "links"), + %{} = link <- Enum.find(links, &(&1["rel"] == "self")), + href when is_binary(href) and href != "" <- link["href"] do + {:ok, href} + else + _ -> nil + end + else + nil + end + + _ -> + nil + end + + true -> + nil + end + end + + defp fetch_follower_ids(remote_actor_id) when is_binary(remote_actor_id) do + remote_actor_id + |> fetch_ap_json!() + |> Map.get("followers") + |> case do + followers_url when is_binary(followers_url) and followers_url != "" -> + followers_url + |> fetch_collection_page_items!() + |> Enum.flat_map(&extract_id/1) + + _ -> + [] + end + end + + defp fetch_collection_page_items!(collection_url) when is_binary(collection_url) do + collection = fetch_ap_json!(collection_url) + + cond do + is_list(collection["orderedItems"]) -> + collection["orderedItems"] + + is_list(collection["items"]) -> + collection["items"] + + is_map(collection["first"]) -> + first = collection["first"] + Map.get(first, "orderedItems") || Map.get(first, "items") || [] + + is_binary(collection["first"]) -> + fetch_collection_page_items!(collection["first"]) + + true -> + [] + end + end + + defp fetch_ap_json!(url) when is_binary(url) do + resp = + req_get!( + url, + headers: [{"accept", "application/activity+json"}] + ) + + ensure_json!(resp.body) + end + + defp extract_id(id) when is_binary(id), do: [id] + defp extract_id(%{"id" => id}) when is_binary(id), do: [id] + defp extract_id(_), do: [] + + defp parse_handle!(handle) when is_binary(handle) do + handle + |> String.trim_leading("@") + |> String.split("@", parts: 2) + |> case do + [username, domain] when username != "" and domain != "" -> + %{username: username, domain: domain} + + _ -> + raise("invalid handle: #{inspect(handle)}") + end + end + + defp actor_id_variants(actor_id) when is_binary(actor_id) do + actor_id = String.trim(actor_id) + + https_variant = + actor_id + |> URI.parse() + |> then(fn uri -> + case uri do + %URI{scheme: "http"} = uri -> URI.to_string(%URI{uri | scheme: "https", port: nil}) + _ -> actor_id + end + end) + + http_variant = + actor_id + |> URI.parse() + |> then(fn uri -> + case uri do + %URI{scheme: "https"} = uri -> URI.to_string(%URI{uri | scheme: "http", port: nil}) + _ -> actor_id + end + end) + + [actor_id, https_variant, http_variant] + |> Enum.uniq() + |> Enum.reject(&(&1 == "")) + end + + defp follow_remote!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + resp = + req_post!( + base_url <> "/api/v1/follows", + headers: [{"authorization", "Bearer " <> access_token}], + form: [{"uri", handle}] + ) + + cond do + resp.status in 200..299 -> + :ok + + resp.status in [404, 405] -> + follow_remote_via_lookup!(base_url, access_token, handle) + + true -> + raise("follow failed (POST /api/v1/follows): #{resp.status}") + end + end + + defp follow_remote_via_lookup!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + account_id = resolve_account_id!(base_url, access_token, handle) + + resp = + req_post!( + base_url <> "/api/v1/accounts/" <> account_id <> "/follow", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + if resp.status in 200..299 do + :ok + else + raise("follow failed (POST /api/v1/accounts/:id/follow): #{resp.status}") + end + end + + defp resolve_account_id!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + acct = String.trim_leading(handle, "@") + + resp = + req_get!( + base_url <> "/api/v1/accounts/lookup", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"acct", acct}] + ) + + cond do + resp.status in 200..299 -> + resp.body + |> ensure_json!() + |> Map.fetch!("id") + + true -> + resolve_account_id_via_search!(base_url, access_token, handle) + end + end + + defp resolve_account_id_via_search!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + acct = String.trim_leading(handle, "@") + + resolve = + fn accounts -> + accounts + |> Enum.find(fn + %{"acct" => ^acct} -> true + _ -> false + end) + |> case do + %{"id" => id} when is_binary(id) and id != "" -> + {:ok, id} + + _ -> + accounts + |> List.first() + |> case do + %{"id" => id} when is_binary(id) and id != "" -> {:ok, id} + _ -> :error + end + end + end + + resp = + req_get!( + base_url <> "/api/v2/search", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"q", handle}, {"type", "accounts"}, {"resolve", "true"}, {"limit", 5}] + ) + + with status when status in 200..299 <- resp.status, + %{} = body <- ensure_json!(resp.body), + accounts when is_list(accounts) <- Map.get(body, "accounts"), + {:ok, account_id} <- resolve.(accounts) do + account_id + else + _ -> + resp = + req_get!( + base_url <> "/api/v1/accounts/search", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"q", handle}, {"resolve", "true"}, {"limit", 5}] + ) + + with status when status in 200..299 <- resp.status, + accounts when is_list(accounts) <- ensure_json!(resp.body), + {:ok, account_id} <- resolve.(accounts) do + account_id + else + _ -> + raise("failed to resolve remote account id for #{handle}") + end + end + end + + defp get_html!(base_url, path, jar) + when is_binary(base_url) and is_binary(path) and is_map(jar) do + resp = + req_get!( + base_url <> path, + headers: cookie_headers(jar) ++ [{"accept", "text/html"}] + ) + + jar = update_cookie_jar(jar, resp) + {to_string(resp.body), jar} + end + + defp post_rails_form!(base_url, path, jar, csrf_token, fields) + when is_binary(base_url) and is_binary(path) and is_map(jar) and is_binary(csrf_token) and + is_list(fields) do + resp = + req_post!( + base_url <> path, + headers: cookie_headers(jar), + form: [{"authenticity_token", csrf_token} | fields] + ) + + jar = update_cookie_jar(jar, resp) + {to_string(resp.body), jar} + end + + defp cookie_headers(jar) when is_map(jar) do + case jar do + jar when map_size(jar) == 0 -> + [] + + jar -> + cookie = + jar + |> Enum.map_join("; ", fn {name, value} -> "#{name}=#{value}" end) + + [{"cookie", cookie}] + end + end + + defp update_cookie_jar(jar, %Req.Response{} = resp) when is_map(jar) do + resp + |> Req.Response.get_header("set-cookie") + |> Enum.reduce(jar, fn set_cookie, jar -> + set_cookie + |> String.split(";", parts: 2) + |> List.first() + |> String.split("=", parts: 2) + |> case do + [name, value] when name != "" -> Map.put(jar, name, value) + _ -> jar + end + end) + end + + defp extract_csrf_token!(html) when is_binary(html) do + case Regex.run(~r/ token + _ -> raise("csrf token not found") + end + end + + defp extract_oauth_code!(html) when is_binary(html) do + with [_, code] <- + Regex.run( + ~r/]*class=[\"'][^\"']*oauth-code[^\"']*[\"'][^>]*value=[\"']([^\"']+)[\"']/i, + html + ) || + Regex.run( + ~r/]*value=[\"']([^\"']+)[\"'][^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"']/i, + html + ) do + String.trim(code) + else + _ -> + case Regex.run(~r/Copy this code back into the client:.*?