diff --git a/.gitignore b/.gitignore index 5ae1a7e..20b11f6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ erl_crash.dump # Temporary files, for example, from tests. /tmp/ +/.nak-e2e-server.log # draw.io temp files .$* diff --git a/README.md b/README.md index 06535c4..05c0a43 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,9 @@ Before opening a PR: ```bash mix precommit ``` + +For external CLI end-to-end checks with `nak`: + +```bash +mix test.nak_e2e +``` diff --git a/config/test.exs b/config/test.exs index fb37050..f840d03 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,8 +2,14 @@ import Config config :logger, level: :warning +test_endpoint_port = + case System.get_env("PARRHESIA_TEST_HTTP_PORT") do + nil -> 0 + value -> String.to_integer(value) + end + config :parrhesia, Parrhesia.Web.Endpoint, - port: 0, + port: test_endpoint_port, ip: {127, 0, 0, 1} config :parrhesia, enable_expiration_worker: false diff --git a/mix.exs b/mix.exs index bfd6e75..a6e0845 100644 --- a/mix.exs +++ b/mix.exs @@ -60,6 +60,7 @@ defmodule Parrhesia.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "test.nak_e2e": ["cmd ./scripts/run_nak_e2e.sh"], # cov: ["cmd mix coveralls.lcov"], lint: ["format --check-formatted", "credo"], precommit: [ @@ -67,7 +68,8 @@ defmodule Parrhesia.MixProject do "compile --warnings-as-errors", "credo --strict --all", "deps.unlock --unused", - "test" + "test", + "test.nak_e2e" ] ] end diff --git a/scripts/run_nak_e2e.sh b/scripts/run_nak_e2e.sh new file mode 100755 index 0000000..298330d --- /dev/null +++ b/scripts/run_nak_e2e.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +export MIX_ENV=test +export PARRHESIA_NAK_E2E=1 + +TEST_HTTP_PORT="${PARRHESIA_NAK_E2E_RELAY_PORT:-$(( (RANDOM % 10000) + 40000 ))}" + +if [[ -z "${PGDATABASE:-}" ]]; then + export PGDATABASE="parrhesia_nak_e2e_test" +fi + +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.drop --quiet || true +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.create --quiet +PARRHESIA_TEST_HTTP_PORT=0 mix ecto.migrate --quiet + +SERVER_LOG="${ROOT_DIR}/.nak-e2e-server.log" +: > "$SERVER_LOG" + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +if ss -ltn "( sport = :${TEST_HTTP_PORT} )" | tail -n +2 | grep -q .; then + echo "Port ${TEST_HTTP_PORT} is already in use. Set PARRHESIA_NAK_E2E_RELAY_PORT to a free port." >&2 + exit 1 +fi + +PARRHESIA_TEST_HTTP_PORT="$TEST_HTTP_PORT" mix run --no-halt >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +READY=0 +for _ in {1..100}; do + if curl -fsS "http://127.0.0.1:${TEST_HTTP_PORT}/health" >/dev/null 2>&1; then + READY=1 + break + fi + sleep 0.1 +done + +if [[ "$READY" -ne 1 ]]; then + echo "Server did not become ready on port ${TEST_HTTP_PORT}" >&2 + tail -n 200 "$SERVER_LOG" >&2 || true + exit 1 +fi + +PARRHESIA_TEST_HTTP_PORT=0 \ + PARRHESIA_NAK_E2E_RELAY_PORT="$TEST_HTTP_PORT" \ + mix test test/parrhesia/e2e/nak_cli_test.exs --no-start --only nak_e2e --timeout 15000 diff --git a/test/parrhesia/e2e/nak_cli_test.exs b/test/parrhesia/e2e/nak_cli_test.exs new file mode 100644 index 0000000..96986fb --- /dev/null +++ b/test/parrhesia/e2e/nak_cli_test.exs @@ -0,0 +1,284 @@ +defmodule Parrhesia.E2E.NakCliTest do + use ExUnit.Case, async: false + + @moduletag :nak_e2e + + setup_all do + if is_nil(System.find_executable("nak")) do + raise "nak executable not found in PATH" + end + + {:ok, _apps} = Application.ensure_all_started(:req) + :ok = wait_for_server() + :ok + end + + setup do + relay_http_url = relay_http_url() + + {:ok, + relay_url: relay_ws_url(relay_http_url), + relay_http_url: relay_http_url, + author_secret_key: random_secret_key()} + end + + test "nak relay returns NIP-11 metadata", %{ + relay_url: relay_url, + relay_http_url: relay_http_url + } do + {ws_output, 0} = run_nak(["-q", "relay", relay_url]) + {http_output, 0} = run_nak(["-q", "relay", relay_http_url]) + + ws_metadata = JSON.decode!(ws_output) + http_metadata = JSON.decode!(http_output) + + assert ws_metadata["name"] == "Parrhesia" + assert 11 in ws_metadata["supported_nips"] + assert 98 in ws_metadata["supported_nips"] + assert http_metadata["name"] == ws_metadata["name"] + end + + test "nak event/req/count/search round-trip", %{ + relay_url: relay_url, + author_secret_key: author_secret_key + } do + nonce = System.unique_integer([:positive]) + + first = + publish_event(relay_url, + sec: author_secret_key, + content: "nak-e2e-alpha-#{nonce}" + ) + + second = + publish_event(relay_url, + sec: author_secret_key, + content: "nak-e2e-beta-#{nonce}" + ) + + events = req_events(relay_url, ["-k", "1", "-a", first["pubkey"], "-l", "10"]) + + assert Enum.any?(events, fn event -> event["id"] == first["id"] end) + assert Enum.any?(events, fn event -> event["id"] == second["id"] end) + + count = count_events(relay_url, ["-k", "1", "-a", first["pubkey"]]) + assert count >= 2 + + [search_result] = req_events(relay_url, ["--search", "alpha-#{nonce}", "-k", "1", "-l", "5"]) + assert search_result["id"] == first["id"] + end + + test "nak user a post -> user b reply -> user a refresh sees reply", %{ + relay_url: relay_url, + author_secret_key: user_a_secret_key + } do + nonce = System.unique_integer([:positive]) + + post = + publish_event(relay_url, + sec: user_a_secret_key, + content: "nak-e2e-parent-#{nonce}" + ) + + user_b_secret_key = random_secret_key() + + reply = + publish_event(relay_url, + sec: user_b_secret_key, + content: "nak-e2e-reply-#{nonce}", + tags: ["e=#{post["id"]}", "p=#{post["pubkey"]}"] + ) + + refresh_results = req_events(relay_url, ["-k", "1", "-e", post["id"], "-l", "10"]) + + assert Enum.any?(refresh_results, fn event -> event["id"] == reply["id"] end) + assert Enum.any?(refresh_results, fn event -> event["pubkey"] == reply["pubkey"] end) + end + + test "nak duplicate publish does not create additional events", %{ + relay_url: relay_url, + author_secret_key: author_secret_key + } do + created_at = System.system_time(:second) + content = "nak-e2e-dup-#{System.unique_integer([:positive])}" + + event = + publish_event(relay_url, + sec: author_secret_key, + content: content, + created_at: created_at + ) + + duplicate = + publish_event(relay_url, + sec: author_secret_key, + content: content, + created_at: created_at + ) + + assert duplicate["id"] == event["id"] + + assert req_events(relay_url, ["-i", event["id"], "-l", "5"]) |> Enum.map(& &1["id"]) == [ + event["id"] + ] + end + + test "nak kind 445 event query with #h filter", %{ + relay_url: relay_url, + author_secret_key: author_secret_key + } do + group_id = String.duplicate("a", 64) + + group_event = + publish_event(relay_url, + sec: author_secret_key, + kind: 445, + tags: ["h=#{group_id}"], + content: Base.encode64("nak-e2e-group") + ) + + assert req_events(relay_url, ["-k", "445", "-t", "h=#{group_id}", "-l", "5"]) + |> Enum.map(& &1["id"]) == [group_event["id"]] + end + + test "nak deletion and vanish lifecycle", %{ + relay_url: relay_url, + author_secret_key: author_secret_key + } do + first = + publish_event(relay_url, + sec: author_secret_key, + content: "nak-e2e-delete-first-#{System.unique_integer([:positive])}" + ) + + second = + publish_event(relay_url, + sec: author_secret_key, + content: "nak-e2e-delete-second-#{System.unique_integer([:positive])}" + ) + + _delete_request = + publish_event(relay_url, + sec: author_secret_key, + kind: 5, + tags: ["e=#{first["id"]}"], + content: "delete" + ) + + assert req_events(relay_url, ["-i", first["id"], "-l", "5"]) == [] + + assert req_events(relay_url, ["-i", second["id"], "-l", "5"]) |> Enum.map(& &1["id"]) == [ + second["id"] + ] + + _vanish_request = + publish_event(relay_url, + sec: author_secret_key, + kind: 62, + content: "vanish" + ) + + assert req_events(relay_url, ["-a", first["pubkey"], "-l", "20"]) == [] + end + + defp publish_event(relay_url, opts) do + sec = Keyword.fetch!(opts, :sec) + kind = Keyword.get(opts, :kind, 1) + content = Keyword.get(opts, :content, "") + tags = Keyword.get(opts, :tags, []) + + created_at_args = + case Keyword.get(opts, :created_at) do + created_at when is_integer(created_at) -> ["--created-at", Integer.to_string(created_at)] + _other -> [] + end + + tag_args = Enum.flat_map(tags, fn tag -> ["-t", tag] end) + + args = + ["-q", "event", "--sec", sec, "-k", Integer.to_string(kind), "-c", content] ++ + created_at_args ++ tag_args ++ [relay_url] + + {output, 0} = run_nak(args) + JSON.decode!(output) + end + + defp req_events(relay_url, filter_args) do + {output, 0} = run_nak(["-q", "req" | filter_args] ++ [relay_url]) + + output + |> String.split("\n", trim: true) + |> Enum.map(&JSON.decode!/1) + end + + defp count_events(relay_url, filter_args) do + {output, 0} = run_nak(["-q", "count" | filter_args] ++ [relay_url]) + + case Regex.run(~r/:\s*(\d+)\s*$/, String.trim(output)) do + [_, count] -> String.to_integer(count) + _other -> flunk("unexpected nak count output: #{inspect(output)}") + end + end + + defp run_nak(args, timeout_ms \\ 5_000) do + shell_command = + ["nak" | args] + |> Enum.map_join(" ", &shell_escape/1) + |> Kernel.<>(" &1") + + task = Task.async(fn -> System.cmd("bash", ["-lc", shell_command]) end) + + case Task.yield(task, timeout_ms) || Task.shutdown(task, :brutal_kill) do + {:ok, {_output, 0} = success} -> + success + + {:ok, {output, status}} -> + flunk("nak command failed with status #{status}: #{shell_command}\n#{output}") + + nil -> + flunk("nak command timed out after #{timeout_ms}ms: #{shell_command}") + end + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\\''") <> "'" + end + + defp wait_for_server do + health_url = base_http_url() <> "/health" + + 1..100 + |> Enum.reduce_while(:error, fn _attempt, _acc -> + case Req.get(health_url, receive_timeout: 500, connect_options: [timeout: 500]) do + {:ok, %{status: 200, body: "ok"}} -> + {:halt, :ok} + + _other -> + Process.sleep(100) + {:cont, :error} + end + end) + |> case do + :ok -> :ok + :error -> raise "server was not ready at #{health_url}" + end + end + + defp relay_http_url do + base_http_url() <> "/relay" + end + + defp base_http_url do + port = System.get_env("PARRHESIA_NAK_E2E_RELAY_PORT") || "4050" + "http://127.0.0.1:#{port}" + end + + defp relay_ws_url(relay_http_url) do + String.replace_prefix(relay_http_url, "http://", "ws://") + end + + defp random_secret_key do + :crypto.strong_rand_bytes(32) + |> Base.encode16(case: :lower) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..eaf59ee 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,8 @@ -ExUnit.start() +exclude_tags = + if System.get_env("PARRHESIA_NAK_E2E") == "1" do + [] + else + [:nak_e2e] + end + +ExUnit.start(exclude: exclude_tags)