2 changed files with 416 additions and 0 deletions
@ -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": ["<pubkey1>"], "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", "<some pubkey>"] has been published |
||||||
|
When the client sends POST /api/events/filter with body {"#p": ["<some pubkey>"], "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/<event_id> |
||||||
|
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/<unknown_id> |
||||||
|
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 |
||||||
@ -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 |
||||||
Loading…
Reference in new issue