diff --git a/test/features/relay_api.feature b/test/features/relay_api.feature new file mode 100644 index 0000000..f61f830 --- /dev/null +++ b/test/features/relay_api.feature @@ -0,0 +1,121 @@ +Feature: Nostr Relay REST API + As a Nostr client (e.g. Jumble at https://jumble.imwald.eu/) + I want to interact with the relay over HTTP + So that I can publish and query Nostr events + + Background: + Given the relay is running at http://localhost:4000 + + # --------------------------------------------------------------------------- + # Discovery + # --------------------------------------------------------------------------- + + Scenario: Client discovers available endpoints + When the client sends GET /api + Then the response status is 200 + And the response lists the available endpoints + + # --------------------------------------------------------------------------- + # Publishing events + # --------------------------------------------------------------------------- + + Scenario: Client publishes a valid kind 1 note + Given a valid signed kind 1 event from keypair 1 + When the client sends POST /api/events with the event body + Then the response status is 201 Created + And the response body contains the published event + + Scenario: Client publishes a kind 0 profile event with metadata + Given a valid signed kind 0 event containing a JSON metadata content field + When the client sends POST /api/events with the event body + Then the response status is 201 Created + + Scenario: Relay rejects a duplicate event + Given a valid signed event has already been published + When the client sends POST /api/events with the same event again + Then the response status is 409 Conflict + + Scenario: Relay rejects an event with an invalid ID + Given a signed event whose ID does not match the hash of its content + When the client sends POST /api/events with the event body + Then the response status is 400 Bad Request + + Scenario: Relay rejects a NIP-70 protected event + Given a valid signed event with a ["-"] protection tag + When the client sends POST /api/events with the event body + Then the response status is 400 Bad Request + And the response error contains "auth-required" + + # --------------------------------------------------------------------------- + # Fetching events (POST /api/events/filter — what most Nostr clients use) + # --------------------------------------------------------------------------- + + Scenario: Client fetches recent notes (kind 1) + Given two kind 1 notes have been published + When the client sends POST /api/events/filter with body {"kinds": [1], "limit": 10} + Then the response status is 200 + And the response contains both kind 1 events + And events are ordered newest first + + Scenario: Client fetches a user profile (kind 0) + Given a kind 0 profile event has been published for keypair 1 + When the client sends POST /api/events/filter with body {"kinds": [0], "authors": [""], "limit": 1} + Then the response status is 200 + And the response contains exactly 1 event + And the event kind is 0 + + Scenario: Client fetches events mentioning a specific pubkey (#p tag) + Given an event tagged with ["p", ""] has been published + When the client sends POST /api/events/filter with body {"#p": [""], "limit": 10} + Then the response status is 200 + And the response contains the tagged event + + Scenario: Client fetches events within a time window + Given two events with different created_at timestamps exist + When the client sends POST /api/events/filter with a "since" before the newer event + Then only the newer event is returned + + Scenario: Filter without a limit is rejected + When the client sends POST /api/events/filter with body {"kinds": [1]} + Then the response status is 400 Bad Request + + Scenario: Filter with a limit over 100 is rejected + When the client sends POST /api/events/filter with body {"limit": 101} + Then the response status is 400 Bad Request + + # --------------------------------------------------------------------------- + # Fetching a single event by ID (GET /api/events/:id) + # --------------------------------------------------------------------------- + + Scenario: Client fetches a specific event by ID + Given a known event has been published + When the client sends GET /api/events/ + Then the response status is 200 + And the response body contains the correct event + + Scenario: Fetching a non-existent event returns 404 + When the client sends GET /api/events/ + Then the response status is 404 Not Found + + # --------------------------------------------------------------------------- + # CORS — required for browser-based clients like Jumble + # --------------------------------------------------------------------------- + + Scenario: Relay includes CORS headers on API responses + When the client sends any request to the API + Then the response includes "Access-Control-Allow-Origin: *" + And the response includes "Access-Control-Allow-Methods" listing GET, POST, DELETE, OPTIONS + + Scenario: Relay handles a browser preflight OPTIONS request + When the client sends OPTIONS /api/events + Then the response status is 200 + And the response includes CORS headers + And the response body is empty + + # --------------------------------------------------------------------------- + # Health check + # --------------------------------------------------------------------------- + + Scenario: Health check returns OK + When the client sends GET /health + Then the response status is 200 diff --git a/test/gc_index_relay_web/relay_integration_test.exs b/test/gc_index_relay_web/relay_integration_test.exs new file mode 100644 index 0000000..b230c76 --- /dev/null +++ b/test/gc_index_relay_web/relay_integration_test.exs @@ -0,0 +1,295 @@ +defmodule GcIndexRelayWeb.RelayIntegrationTest do + @moduledoc """ + Integration probe of the relay REST API, covering the scenarios a browser-based + Nostr client (e.g. Jumble at https://jumble.imwald.eu/) would exercise. + + Scenarios are mapped 1:1 to test/features/relay_api.feature. + Run with: mix test.integration test/gc_index_relay_web/relay_integration_test.exs + """ + + use GcIndexRelayWeb.ConnCase + + import GcIndexRelay.NostrFixtures + + @moduletag :integration + + setup %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + + {:ok, conn: conn} + end + + # --------------------------------------------------------------------------- + # Discovery + # --------------------------------------------------------------------------- + + describe "GET /api — discovery" do + test "client discovers available endpoints", %{conn: conn} do + # When + conn = get(conn, ~p"/api") + + # Then + assert %{"endpoints" => endpoints} = json_response(conn, 200) + paths = Enum.map(endpoints, & &1["path"]) + assert "/api/events" in paths + assert "/api/events/:id" in paths + assert "/api/events/filter" in paths or "/api/events/:id" in paths + end + end + + # --------------------------------------------------------------------------- + # Publishing events (POST /api/events) + # --------------------------------------------------------------------------- + + describe "POST /api/events — publishing" do + test "client publishes a valid kind 1 note", %{conn: conn} do + # Given + event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"}) + + # When + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # Then + assert %{"data" => data} = json_response(conn, 201) + assert data["id"] == event.id + assert data["kind"] == 1 + assert data["content"] == "hello nostr" + end + + test "client publishes a kind 0 profile event with metadata content", %{conn: conn} do + # Given — kind 0 content is a JSON string per NIP-01 + metadata = Jason.encode!(%{name: "testuser", about: "a test profile", picture: ""}) + event = valid_pub_event_fixture(%{kind: 0, content: metadata}) + + # When + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # Then — event was stored and returned + assert %{"data" => data} = json_response(conn, 201) + assert data["kind"] == 0 + assert data["content"] == metadata + end + + test "relay rejects a duplicate event with 409", %{conn: conn} do + # Given + event = valid_pub_event_fixture() + post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # When — same event again + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # Then + assert json_response(conn, 409) + end + + test "relay rejects an event with a tampered ID with 400", %{conn: conn} do + # Given — ID does not match hash of content + event = invalid_id_pub_event_fixture() + + # When + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # Then + assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400) + assert detail =~ "invalid" + end + + test "relay rejects a NIP-70 protected event with 400", %{conn: conn} do + # Given — event has the ["-"] protection tag + event = valid_pub_event_fixture(%{tags: [["-"]]}) + + # When + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + + # Then + assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400) + assert detail =~ "auth-required" + end + end + + # --------------------------------------------------------------------------- + # Querying events (POST /api/events/filter) + # --------------------------------------------------------------------------- + + describe "POST /api/events/filter — querying" do + test "client fetches recent kind 1 notes, newest first", %{conn: conn} do + # Given + _older = + valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001}) + |> then(&GcIndexRelay.Nostr.create_event/1) + + _newer = + valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2}) + |> then(&GcIndexRelay.Nostr.create_event/1) + + # When + conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10}) + + # Then + assert %{"data" => events} = json_response(conn, 200) + assert length(events) == 2 + [first, second] = events + assert first["created_at"] >= second["created_at"] + end + + test "client fetches a user profile (kind 0) by author", %{conn: conn} do + # Given + %{keypair1: kp} = test_keypairs() + metadata = Jason.encode!(%{name: "alice"}) + event = valid_pub_event_fixture(%{kind: 0, content: metadata}) + {:ok, _} = GcIndexRelay.Nostr.create_event(event) + + # When + conn = + post(conn, ~p"/api/events/filter", %{ + "kinds" => [0], + "authors" => [kp.public_key_hex], + "limit" => 1 + }) + + # Then + assert %{"data" => [profile]} = json_response(conn, 200) + assert profile["kind"] == 0 + assert profile["pubkey"] == kp.public_key_hex + end + + test "client fetches events mentioning a pubkey via #p tag", %{conn: conn} do + # Given — an event tagging another user + mentioned_pubkey = String.duplicate("ab", 32) + event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]}) + {:ok, _} = GcIndexRelay.Nostr.create_event(event) + + _unrelated = + valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2}) + |> then(&GcIndexRelay.Nostr.create_event/1) + + # When + conn = post(conn, ~p"/api/events/filter", %{"#p" => [mentioned_pubkey], "limit" => 10}) + + # Then — only the tagged event is returned + assert %{"data" => events} = json_response(conn, 200) + assert length(events) == 1 + assert hd(events)["id"] == event.id + end + + test "client fetches events within a time window", %{conn: conn} do + # Given + _old = + valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000}) + |> then(&GcIndexRelay.Nostr.create_event/1) + + _new = + valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2}) + |> then(&GcIndexRelay.Nostr.create_event/1) + + # When — only events after 1_600_000_000 + conn = + post(conn, ~p"/api/events/filter", %{ + "since" => 1_600_000_000, + "limit" => 10 + }) + + # Then + assert %{"data" => events} = json_response(conn, 200) + assert length(events) == 1 + assert hd(events)["created_at"] == 1_640_000_001 + end + + test "filter without a limit is rejected with 400", %{conn: conn} do + # When + conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1]}) + + # Then + assert json_response(conn, 400) + end + + test "filter with limit over 100 is rejected with 400", %{conn: conn} do + # When + conn = post(conn, ~p"/api/events/filter", %{"limit" => 101}) + + # Then + assert json_response(conn, 400) + end + end + + # --------------------------------------------------------------------------- + # Fetching a single event (GET /api/events/:id) + # --------------------------------------------------------------------------- + + describe "GET /api/events/:id — single event lookup" do + test "client fetches a specific event by ID", %{conn: conn} do + # Given + event = valid_pub_event_fixture() + {:ok, db_event} = GcIndexRelay.Nostr.create_event(event) + hex_id = Base.encode16(db_event.id, case: :lower) + + # When + conn = get(conn, ~p"/api/events/#{hex_id}") + + # Then + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == hex_id + assert data["content"] == event.content + end + + test "fetching a non-existent event returns 404", %{conn: conn} do + # When + conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}") + + # Then + assert json_response(conn, 404) + end + end + + # --------------------------------------------------------------------------- + # CORS — required for browser-based clients like Jumble + # --------------------------------------------------------------------------- + + describe "CORS headers — browser client compatibility" do + test "relay includes CORS headers on a GET response", %{conn: conn} do + # When + conn = get(conn, ~p"/api") + + # Then + assert get_resp_header(conn, "access-control-allow-origin") == ["*"] + methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("") + assert methods =~ "GET" + assert methods =~ "POST" + assert methods =~ "DELETE" + end + + test "relay includes CORS headers on a POST response", %{conn: conn} do + # When + conn = post(conn, ~p"/api/events/filter", %{"limit" => 10}) + + # Then + assert get_resp_header(conn, "access-control-allow-origin") == ["*"] + end + + test "relay responds 200 to a browser preflight OPTIONS request", %{conn: conn} do + # When — browser sends preflight before the real request + conn = options(conn, "/api/events") + + # Then + assert conn.status == 200 + assert conn.resp_body == "" + assert get_resp_header(conn, "access-control-allow-origin") == ["*"] + methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("") + assert methods =~ "OPTIONS" + end + end + + # --------------------------------------------------------------------------- + # Health check + # --------------------------------------------------------------------------- + + describe "GET /health — health check" do + test "health check returns 200", %{conn: conn} do + conn = get(conn, "/health") + assert conn.status == 200 + end + end +end