From 1199369dd93a85ca32e60ddc589d0b0638bcb1e7 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Sat, 14 Mar 2026 00:15:06 +0100 Subject: [PATCH] test: NAME: nak - the nostr army knife command-line tool USAGE: nak [global options] [command [command options]] VERSION: 0.17.3 COMMANDS: event generates an encoded event and either prints it or sends it to a set of relays req generates encoded REQ messages and optionally use them to talk to relays filter applies an event filter to an event to see if it matches. fetch fetches events related to the given nip19 or nip05 code from the included relay hints or the author's outbox relays. count generates encoded COUNT messages and optionally use them to talk to relays decode decodes nip19, nip21, nip05 or hex entities encode encodes notes and other stuff to nip19 entities key operations on secret keys: generate, derive, encrypt, decrypt verify checks the hash and signature of an event given through stdin or as the first argument relay gets the relay information document for the given relay, as JSON admin manage relays using the relay management API bunker starts a nip46 signer daemon with the given --sec key serve starts an in-memory relay for testing purposes blossom an army knife for blossom things dekey handles NIP-4E decoupled encryption keys encrypt encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64 decrypt decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext gift gift-wraps (or unwraps) an event according to NIP-59 outbox manage outbox relay hints database wallet displays the current wallet balance mcp pander to the AI gods curl calls curl but with a nip98 header fs mount a FUSE filesystem that exposes Nostr events as files. publish publishes a note with content from stdin git git-related operations nip list NIPs or get the description of a NIP from its number sync sync events between two relays using negentropy spell downloads a spell event and executes its REQ request help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --quiet, -q do not print logs and info messages to stderr, use -qq to also not print anything to stdout (default: false) --verbose, -v print more stuff than normally (default: false) --help, -h show help --version prints the version (default: false) based E2E tests --- .gitignore | 1 + README.md | 6 + config/test.exs | 8 +- mix.exs | 4 +- scripts/run_nak_e2e.sh | 57 ++++++ test/parrhesia/e2e/nak_cli_test.exs | 284 ++++++++++++++++++++++++++++ test/test_helper.exs | 9 +- 7 files changed, 366 insertions(+), 3 deletions(-) create mode 100755 scripts/run_nak_e2e.sh create mode 100644 test/parrhesia/e2e/nak_cli_test.exs 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)