2 changed files with 416 additions and 0 deletions
@ -0,0 +1,121 @@
@@ -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 @@
@@ -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