Browse Source

thinned out unused and inefficient/broken stuff

handle e/E tags more clearly
utilized valid event from prod for tests
created test complete run
sandboxed tests and handle concurrent tests
test/local-setup
Silberengel 3 weeks ago
parent
commit
f76e047472
  1. 7
      .env.example
  2. 2
      .formatter.exs
  3. 16
      README.md
  4. 47
      compose.yaml
  5. 9
      config/config.exs
  6. 5
      config/dev.exs
  7. 6
      config/prod.exs
  8. 39
      config/runtime.exs
  9. 9
      config/test.exs
  10. 1
      lib/gc_index_relay/application.ex
  11. 3
      lib/gc_index_relay/mailer.ex
  12. 26
      lib/gc_index_relay/nostr.ex
  13. 43
      lib/gc_index_relay/nostr/filter.ex
  14. 48
      lib/gc_index_relay/nostr/pub_event.ex
  15. 2
      lib/gc_index_relay_web.ex
  16. 7
      lib/gc_index_relay_web/controllers/api_controller.ex
  17. 15
      lib/gc_index_relay_web/controllers/event_controller.ex
  18. 11
      lib/gc_index_relay_web/controllers/fallback_controller.ex
  19. 49
      lib/gc_index_relay_web/controllers/filter_controller.ex
  20. 14
      lib/gc_index_relay_web/endpoint.ex
  21. 27
      lib/gc_index_relay_web/plugs/cors.ex
  22. 19
      lib/gc_index_relay_web/plugs/relay_info.ex
  23. 34
      lib/gc_index_relay_web/router.ex
  24. 39
      lib/mix/tasks/test/integration.ex
  25. 17
      mix.exs
  26. 9
      mix.lock
  27. 11
      priv/repo/seeds.exs
  28. 22
      priv/static/swagger.json
  29. 13
      test/gc_index_relay/nostr/filter_test.exs
  30. 65
      test/gc_index_relay/nostr/pub_event_test.exs
  31. 7
      test/gc_index_relay/nostr/validator_test.exs
  32. 17
      test/gc_index_relay/nostr_test.exs
  33. 2
      test/gc_index_relay_web/controllers/error_html_test.exs
  34. 2
      test/gc_index_relay_web/controllers/error_json_test.exs
  35. 46
      test/gc_index_relay_web/controllers/filter_controller_test.exs
  36. 25
      test/support/conn_case.ex
  37. 24
      test/support/data_case.ex
  38. 36
      test/support/fixtures/nostr_fixtures.ex

7
.env.example

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
# Copy this file to .env and fill in values, then run: source .env
# The defaults below match the Docker container started by setup.sh.
# The defaults below match setup.sh and compose.yaml: Postgres on host port 5455 (maps to 5432 in the container).
export POSTGRES_HOST=localhost
export POSTGRES_PORT=5455
@ -7,3 +7,8 @@ export POSTGRES_USER=postgres @@ -7,3 +7,8 @@ export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev
export REQUIRE_DB=true
# Optional — only if you use docker compose for setup/migrator/mercury (override compose defaults):
# export POSTGRES_RUNTIME_USER=gc_index_relay
# export POSTGRES_RUNTIME_PASSWORD=gc_index_relay_runtime
# export SECRET_KEY_BASE="$(mix phx.gen.secret)"

2
.formatter.exs

@ -2,5 +2,5 @@ @@ -2,5 +2,5 @@
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
]

16
README.md

@ -59,6 +59,18 @@ export REQUIRE_DB=true @@ -59,6 +59,18 @@ export REQUIRE_DB=true
mix setup
```
### Docker Compose (dev)
Postgres is published on **localhost:5455**, same as the manual `docker run` and `setup.sh` flow. The stack works **without** a `.env` file (compose supplies defaults for DB names, the app DB role, and a dev-only `SECRET_KEY_BASE`). Override with `.env` if you want different passwords.
```bash
docker compose -f compose.yaml up -d
```
Use `POSTGRES_PORT=5455` and `POSTGRES_HOST=localhost` in `.env` when running `mix` on the host against this database (compose services talk to Postgres via the hostname `postgres` on the Docker network, not `localhost`).
To reset the compose database volume: `docker compose -f compose.yaml down -v`.
### Starting the server
```bash
@ -85,15 +97,13 @@ Unit tests (no database required): @@ -85,15 +97,13 @@ Unit tests (no database required):
mix test.unit
```
Integration tests (requires the database to be running). You must have `REQUIRE_DB=true` (included in `.env` from `setup.sh`) so the Repo starts under test:
Integration tests (requires the database to be running). The `mix test.integration` task runs a subprocess with `REQUIRE_DB=true` so the Repo starts under test—you do not need to export it yourself. Still `source .env` (or set `POSTGRES_*`) so the test database host and port match your container:
```bash
source .env
mix test.integration
```
Without `.env`: `REQUIRE_DB=true mix test.integration`
Run the full integration probe against the relay API (covers all endpoints, CORS, NIP-11, NIP-70):
```bash

47
compose.yaml

@ -3,15 +3,16 @@ services: @@ -3,15 +3,16 @@ services:
image: docker.io/apache/age:release_PG17_1.6.0
container_name: postgress_01
restart: unless-stopped
user: 1000:1000 # Should match host user
# Host port matches setup.sh, .env.example, and config defaults (5455 → container 5432).
ports:
- "5432:5432"
- "5455:5432"
# Named volume avoids host uid mismatches (do not combine image postgres user with user: 1000 + ./pgdata).
volumes:
- ./pgdata:/var/lib/postgresql/data # Ensure host user owns the ./pgdata directory
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
command: >
postgres
deploy:
@ -22,18 +23,8 @@ services: @@ -22,18 +23,8 @@ services:
reservations:
cpus: "0.50"
memory: 512M
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
read_only: false
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-gc_index_relay_dev}"]
interval: 10s
timeout: 5s
retries: 5
@ -48,12 +39,13 @@ services: @@ -48,12 +39,13 @@ services:
postgres:
condition: service_healthy
environment:
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD}
# Docker DNS name of this service (not localhost from .env — that is for host-side mix).
POSTGRES_HOST: postgres
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev}
POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:-gc_index_relay}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}
migrator:
build:
@ -67,8 +59,9 @@ services: @@ -67,8 +59,9 @@ services:
setup:
condition: service_completed_successfully
environment:
DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
# Inside the compose network Postgres listens on 5432; 5455 is only the host publish port.
DATABASE_URL: "ecto://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000}
mercury:
build:
@ -84,8 +77,8 @@ services: @@ -84,8 +77,8 @@ services:
ports:
- "4000:4000"
environment:
DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER}:${POSTGRES_RUNTIME_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER:-gc_index_relay}:${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000}
volumes:
pgdata:

9
config/config.exs

@ -30,15 +30,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint, @@ -30,15 +30,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
pubsub_server: GcIndexRelay.PubSub,
live_view: [signing_salt: "CiEK2Shl"]
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :gc_index_relay, GcIndexRelay.Mailer, adapter: Swoosh.Adapters.Local
config :gc_index_relay, :phoenix_swagger,
swagger_files: %{
"priv/static/swagger.json" => [

5
config/dev.exs

@ -60,7 +60,7 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint, @@ -60,7 +60,7 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
]
]
# Enable dev routes for dashboard and mailbox
# Enable dev routes for LiveDashboard
config :gc_index_relay, dev_routes: true
# Do not include metadata nor timestamps in development logs
@ -80,6 +80,3 @@ config :phoenix_live_view, @@ -80,6 +80,3 @@ config :phoenix_live_view,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

6
config/prod.exs

@ -15,12 +15,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint, @@ -15,12 +15,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
hosts: ["localhost", "127.0.0.1"]
]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info

39
config/runtime.exs

@ -23,14 +23,17 @@ end @@ -23,14 +23,17 @@ end
config :gc_index_relay, GcIndexRelayWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
# Defaults match setup.sh / .env.example (Apache AGE on host port 5455). Production
# replaces this with DATABASE_URL in the block below.
config :gc_index_relay, GcIndexRelay.Repo,
hostname: System.get_env("POSTGRES_HOST") || "localhost",
port: String.to_integer(System.get_env("POSTGRES_PORT") || "5455"),
username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("POSTGRES_PASSWORD") || "postgres",
database: System.get_env("POSTGRES_DB") || "gc_index_relay_dev"
# Host-side dev/test: DB published on localhost:5455 (see setup.sh / .env.example).
# Do not set this in :prod: DATABASE_URL omits port on purpose; merging these options
# would keep port 5455 while the hostname came from the URL (e.g. postgres in Docker).
if config_env() != :prod do
config :gc_index_relay, GcIndexRelay.Repo,
hostname: System.get_env("POSTGRES_HOST") || "localhost",
port: String.to_integer(System.get_env("POSTGRES_PORT") || "5455"),
username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("POSTGRES_PASSWORD") || "postgres",
database: System.get_env("POSTGRES_DB") || "gc_index_relay_dev"
end
if config_env() == :prod do
database_url =
@ -64,8 +67,6 @@ if config_env() == :prod do @@ -64,8 +67,6 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
config :gc_index_relay, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :gc_index_relay, GcIndexRelayWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
@ -139,22 +140,4 @@ if config_env() == :prod do @@ -139,22 +140,4 @@ if config_env() == :prod do
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :gc_index_relay, GcIndexRelay.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

9
config/test.exs

@ -2,6 +2,9 @@ import Config @@ -2,6 +2,9 @@ import Config
config :gc_index_relay, :start_repo, System.get_env("REQUIRE_DB") == "true"
# Allow ConnCase HTTP tests to use the same SQL.Sandbox checkout as the test process (see Endpoint).
config :gc_index_relay, sql_sandbox: true
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
@ -25,12 +28,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint, @@ -25,12 +28,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
secret_key_base: "gSUz4Ek3rc6PKcY/imWwjsMbwk8g4+aS5HmD1/MyAmqlbSw+r0V83NjR7H0jnwI6",
server: false
# In test we don't send emails
config :gc_index_relay, GcIndexRelay.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning

1
lib/gc_index_relay/application.ex

@ -11,7 +11,6 @@ defmodule GcIndexRelay.Application do @@ -11,7 +11,6 @@ defmodule GcIndexRelay.Application do
[
GcIndexRelayWeb.Telemetry,
maybe_repo(),
{DNSCluster, query: Application.get_env(:gc_index_relay, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: GcIndexRelay.PubSub},
# Start a worker by calling: GcIndexRelay.Worker.start_link(arg)
# {GcIndexRelay.Worker, arg},

3
lib/gc_index_relay/mailer.ex

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
defmodule GcIndexRelay.Mailer do
use Swoosh.Mailer, otp_app: :gc_index_relay
end

26
lib/gc_index_relay/nostr.ex

@ -42,13 +42,21 @@ defmodule GcIndexRelay.Nostr do @@ -42,13 +42,21 @@ defmodule GcIndexRelay.Nostr do
with {:ok, filter} <- Filter.from_map(filter_map),
events <-
from(e in Event)
|> Filter.apply(filter),
pub_events <-
Enum.map(events, fn event ->
{:ok, pub_event} = PubEvent.from_db(event)
pub_event
end) do
{:ok, pub_events}
|> Filter.apply(filter) do
pub_events_from_db(events)
end
end
defp pub_events_from_db(events) do
Enum.reduce_while(events, {:ok, []}, fn event, {:ok, acc} ->
case PubEvent.from_db(event) do
{:ok, pub_event} -> {:cont, {:ok, [pub_event | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
|> case do
{:ok, list} -> {:ok, Enum.reverse(list)}
{:error, _} = err -> err
end
end
@ -58,8 +66,8 @@ defmodule GcIndexRelay.Nostr do @@ -58,8 +66,8 @@ defmodule GcIndexRelay.Nostr do
def create_event(event) when is_struct(event, PubEvent) do
with {:ok, event} <- Validator.validate_id(event),
{:ok, event} <- Validator.validate_signature(event),
{:ok, event} <- Validator.validate_not_protected(event) do
db_event = PubEvent.to_db(event)
{:ok, event} <- Validator.validate_not_protected(event),
{:ok, db_event} <- PubEvent.to_db(event) do
tags_as_maps = Enum.map(db_event.tags, &Map.from_struct/1)
attrs = db_event |> Map.from_struct() |> Map.put(:tags, tags_as_maps)

43
lib/gc_index_relay/nostr/filter.ex

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
defmodule GcIndexRelay.Nostr.Filter do
@moduledoc """
An implementation of Nostr filters, including a struct representation and parsing and validation
functions.
Nostr NIP-01 filters: struct, parsing, validation, and query building.
Single-letter tag filter keys (`#p`, `#P`, `#e`, `#E`, …) are **case-sensitive**: uppercase and
lowercase names refer to different tags (e.g. NIP-22 uses `P`/`p`, `E`/`e`, `K`/`k` for distinct
semantics). Do not fold case when matching stored tag names.
"""
alias GcIndexRelay.Nostr.Event
@ -24,9 +27,11 @@ defmodule GcIndexRelay.Nostr.Filter do @@ -24,9 +27,11 @@ defmodule GcIndexRelay.Nostr.Filter do
@spec from_map(map()) :: {:ok, t()} | {:error, String.t()}
def from_map(map) when is_map(map) do
# Extract tag filters (keys starting with "#")
# Keys `#p` and `#P` (and likewise for every letter) are distinct filters; preserve case.
tags =
for {"#" <> k, v} <- map,
String.length(k) == 1,
String.match?(k, ~r/^[a-zA-Z]$/),
do: {k, v},
into: %{}
@ -62,24 +67,36 @@ defmodule GcIndexRelay.Nostr.Filter do @@ -62,24 +67,36 @@ defmodule GcIndexRelay.Nostr.Filter do
defp validate_not_empty(_map), do: :ok
# Validate that only known filter keys are present
# Validate that only known filter keys are present. `#` keys must be exactly `#` + one letter.
@spec validate_known_keys(map()) :: :ok | {:error, String.t()}
defp validate_known_keys(map) do
known_keys = ["ids", "authors", "kinds", "since", "until", "limit"]
unknown_keys =
map
|> Map.keys()
|> Enum.reject(fn key ->
key in known_keys or String.starts_with?(key, "#")
end)
case Enum.find(Map.keys(map), &invalid_filter_map_key?(&1, known_keys)) do
nil -> :ok
<<"#", _::binary>> = key -> {:error, invalid_hash_filter_key_message(key)}
key -> {:error, "Unknown filter key: '#{key}'"}
end
end
case unknown_keys do
[] -> :ok
[key | _] -> {:error, "Unknown filter key: '#{key}'"}
defp invalid_filter_map_key?(key, known_keys) do
cond do
key in known_keys -> false
valid_tag_filter_key?(key) -> false
true -> true
end
end
defp valid_tag_filter_key?(<<"#", letter::binary-size(1)>>) do
String.match?(letter, ~r/^[a-zA-Z]$/)
end
defp valid_tag_filter_key?(_), do: false
defp invalid_hash_filter_key_message(key) do
"Invalid tag key '#{key}': must be a single letter (a-z, A-Z)"
end
@spec validate_ids([String.t()] | nil) :: {:ok, [String.t()] | nil} | {:error, String.t()}
defp validate_ids(nil), do: {:ok, nil}
defp validate_ids([]), do: {:ok, nil}

48
lib/gc_index_relay/nostr/pub_event.ex

@ -25,36 +25,42 @@ defmodule GcIndexRelay.Nostr.PubEvent do @@ -25,36 +25,42 @@ defmodule GcIndexRelay.Nostr.PubEvent do
@doc """
Converts a `GcIndexRelay.Nostr.PubEvent` to its corresponding `GcIndexRelay.Nostr.Event` and
`GcIndexRelay.Nostr.Tag` representations.
Returns an Ecto for `GcIndexRelay.Nostr.Event`.
"""
@spec to_db(t()) :: Ecto.Schema.t()
def to_db(%__MODULE__{tags: tags} = pub_event) do
%Event{} = event = to_event(pub_event)
%{event | tags: to_tags(tags)}
@spec to_db(t()) :: {:ok, Event.t()} | {:error, atom()}
def to_db(%__MODULE__{} = pub_event) do
tags = pub_event.tags || []
with {:ok, event} <- to_event(pub_event) do
{:ok, %{event | tags: to_tags(tags)}}
end
end
@spec to_event(t()) :: Ecto.Schema.t()
defp to_event(%__MODULE__{} = pub_event)
when is_binary(pub_event.id) and is_binary(pub_event.pubkey) and
is_integer(pub_event.created_at) and is_integer(pub_event.kind) and
is_binary(pub_event.sig) do
with {:ok, id} <- Base.decode16(pub_event.id, case: :lower),
@spec to_event(t()) :: {:ok, Event.t()} | {:error, atom()}
defp to_event(%__MODULE__{} = pub_event) do
with true <- pub_event_fields_valid?(pub_event),
{:ok, id} <- Base.decode16(pub_event.id, case: :lower),
{:ok, pubkey} <- Base.decode16(pub_event.pubkey, case: :lower),
{:ok, signature} <- Base.decode16(pub_event.sig, case: :lower) do
%Event{
id: id,
pubkey: pubkey,
created_at: DateTime.from_unix!(pub_event.created_at),
kind: pub_event.kind,
content: pub_event.content,
sig: signature
}
{:ok,
%Event{
id: id,
pubkey: pubkey,
created_at: DateTime.from_unix!(pub_event.created_at),
kind: pub_event.kind,
content: pub_event.content,
sig: signature
}}
else
error -> {:error, error}
false -> {:error, :invalid_event}
:error -> {:error, :invalid_hex}
end
end
defp pub_event_fields_valid?(%__MODULE__{} = p) do
is_binary(p.id) and is_binary(p.pubkey) and is_integer(p.created_at) and is_integer(p.kind) and
is_binary(p.sig) and is_list(p.tags || [])
end
@spec to_tags([[String.t()]]) :: [Ecto.Schema.t()]
defp to_tags(tags) when is_list(tags) do
for t <- tags do

2
lib/gc_index_relay_web.ex

@ -17,7 +17,7 @@ defmodule GcIndexRelayWeb do @@ -17,7 +17,7 @@ defmodule GcIndexRelayWeb do
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def static_paths, do: ~w(assets images favicon.ico robots.txt)
def router do
quote do

7
lib/gc_index_relay_web/controllers/api_controller.ex

@ -2,9 +2,12 @@ defmodule GcIndexRelayWeb.ApiController do @@ -2,9 +2,12 @@ defmodule GcIndexRelayWeb.ApiController do
use GcIndexRelayWeb, :controller
def index(conn, _params) do
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info)
json(conn, %{
relay: "Mercury Index-Relay",
version: Application.spec(:gc_index_relay, :vsn) |> to_string(),
relay: Keyword.fetch!(relay_info, :name),
version:
Keyword.get(relay_info, :version, Application.spec(:gc_index_relay, :vsn)) |> to_string(),
endpoints: [
%{
method: "GET",

15
lib/gc_index_relay_web/controllers/event_controller.ex

@ -19,9 +19,20 @@ defmodule GcIndexRelayWeb.EventController do @@ -19,9 +19,20 @@ defmodule GcIndexRelayWeb.EventController do
response(400, "BadRequest")
end
@pub_event_keys ~w(id pubkey created_at kind tags content sig)
def create(conn, %{"event" => event_params}) do
atoms_map = Map.new(event_params, fn {k, v} -> {String.to_existing_atom(k), v} end)
pub_event = struct(PubEvent, atoms_map)
pub_event =
@pub_event_keys
|> Enum.reduce(%{}, fn key, acc ->
case Map.get(event_params, key) do
nil -> acc
v -> Map.put(acc, String.to_existing_atom(key), v)
end
end)
|> Map.put_new(:tags, [])
|> Map.put_new(:content, "")
|> then(&struct(PubEvent, &1))
with {:ok, _event} <- Nostr.create_event(pub_event) do
conn

11
lib/gc_index_relay_web/controllers/fallback_controller.ex

@ -28,6 +28,13 @@ defmodule GcIndexRelayWeb.FallbackController do @@ -28,6 +28,13 @@ defmodule GcIndexRelayWeb.FallbackController do
|> json(%{errors: %{detail: message}})
end
# Atom errors (e.g. from PubEvent.to_db/1).
def call(conn, {:error, reason}) when is_atom(reason) and reason != :not_found do
conn
|> put_status(:bad_request)
|> json(%{errors: %{detail: atom_error_message(reason)}})
end
# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do
conn
@ -42,4 +49,8 @@ defmodule GcIndexRelayWeb.FallbackController do @@ -42,4 +49,8 @@ defmodule GcIndexRelayWeb.FallbackController do
_ -> false
end)
end
defp atom_error_message(:invalid_hex), do: "Invalid hexadecimal encoding in event fields"
defp atom_error_message(:invalid_event), do: "Invalid event structure"
defp atom_error_message(_), do: "Bad request"
end

49
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -83,15 +83,26 @@ defmodule GcIndexRelayWeb.FilterController do @@ -83,15 +83,26 @@ defmodule GcIndexRelayWeb.FilterController do
@spec validate_param_values(map()) :: {:ok, map()} | {:error, String.t()}
def validate_param_values(params) do
%{"limit" => limit} = params
with {:ok, limit} <- parse_limit_value(Map.get(params, "limit")) do
if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."}
else
{:ok, Map.put(params, "limit", limit)}
end
end
end
if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."}
else
{:ok, params}
defp parse_limit_value(v) when is_integer(v), do: {:ok, v}
defp parse_limit_value(v) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> {:ok, int}
_ -> {:error, "Invalid limit value: must be an integer"}
end
end
defp parse_limit_value(_), do: {:error, "Invalid limit value: must be an integer"}
# Parse query parameters into a NIP-01 filter map
@spec parse_query_params(map()) :: {:ok, map()} | {:error, String.t()}
defp parse_query_params(params) do
@ -151,14 +162,28 @@ defmodule GcIndexRelayWeb.FilterController do @@ -151,14 +162,28 @@ defmodule GcIndexRelayWeb.FilterController do
end)
end
# Parse filter keys that represent tags. Note that only single-letter keys are treated as tags;
# all other keys are passed through unchanged.
# Bare `p=` / `P=` and `#p` / `#P` map to NIP-01 tag filters; letter case is significant (NIP-22).
@spec parse_tag(String.t()) :: String.t()
defp parse_tag(key) do
if byte_size(key) == 1 and
((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")),
do: "#" <> key,
else: key
cond do
single_bare_letter_tag_key?(key) ->
"#" <> key
single_letter_hash_tag_key?(key) ->
key
true ->
key
end
end
defp single_bare_letter_tag_key?(key) do
String.length(key) == 1 and String.match?(key, ~r/^[a-zA-Z]$/)
end
defp single_letter_hash_tag_key?(key) do
String.starts_with?(key, "#") and String.length(key) == 2 and
String.match?(String.at(key, 1), ~r/^[a-zA-Z]$/)
end
# Parse individual parameter based on its key
@ -210,7 +235,7 @@ defmodule GcIndexRelayWeb.FilterController do @@ -210,7 +235,7 @@ defmodule GcIndexRelayWeb.FilterController do
# Handle single-letter tag filters without "#" prefix (e.g., "p" instead of "#p")
# The "#" is trimmed by URL fragment parsing; bare single-letter keys are treated as tag filters
defp parse_param(<<letter>> = _key, value) when letter in ?a..?z do
defp parse_param(<<letter>> = _key, value) when letter in ?a..?z or letter in ?A..?Z do
{:ok, String.split(value, ",")}
end
end

14
lib/gc_index_relay_web/endpoint.ex

@ -1,6 +1,12 @@ @@ -1,6 +1,12 @@
defmodule GcIndexRelayWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :gc_index_relay
# Use runtime config so the plug is active whenever test.exs sets :sql_sandbox (compile_env
# would omit the plug if Endpoint was last compiled in an env without that key).
@sandbox_plug_opts Phoenix.Ecto.SQL.Sandbox.init([])
plug :maybe_sql_sandbox
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@ -54,4 +60,12 @@ defmodule GcIndexRelayWeb.Endpoint do @@ -54,4 +60,12 @@ defmodule GcIndexRelayWeb.Endpoint do
plug GcIndexRelayWeb.Plugs.CORS
plug GcIndexRelayWeb.Plugs.RelayInfo
plug GcIndexRelayWeb.Router
def maybe_sql_sandbox(conn, _opts) do
if Application.get_env(:gc_index_relay, :sql_sandbox, false) do
Phoenix.Ecto.SQL.Sandbox.call(conn, @sandbox_plug_opts)
else
conn
end
end
end

27
lib/gc_index_relay_web/plugs/cors.ex

@ -42,23 +42,26 @@ defmodule GcIndexRelayWeb.Plugs.CORS do @@ -42,23 +42,26 @@ defmodule GcIndexRelayWeb.Plugs.CORS do
defp resolve_allow_origin(_conn, "*"), do: "*"
defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do
cond do
"*" in allow_origins ->
"*"
Enum.any?(allow_origins, &(&1 == "*")) ->
"*"
true ->
case get_req_header(conn, "origin") do
[origin] -> if Enum.member?(allow_origins, origin), do: origin, else: nil
_ -> nil
end
if "*" in allow_origins do
"*"
else
origin_from_allowlist(conn, allow_origins)
end
end
defp resolve_allow_origin(_conn, _), do: "*"
defp origin_from_allowlist(conn, allow_origins) do
case get_req_header(conn, "origin") do
[origin] -> origin_if_allowed(origin, allow_origins)
_ -> nil
end
end
defp origin_if_allowed(origin, allow_origins) do
if Enum.member?(allow_origins, origin), do: origin, else: nil
end
defp restrictive_allowlist?("*"), do: false
defp restrictive_allowlist?(list) when is_list(list) do

19
lib/gc_index_relay_web/plugs/relay_info.ex

@ -65,15 +65,22 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do @@ -65,15 +65,22 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do
map
url when is_binary(url) ->
if String.starts_with?(url, ["http://", "https://"]) do
map
else
path = if String.starts_with?(url, "/"), do: url, else: "/#{url}"
Map.put(map, key, "#{base_url}#{path}")
end
resolve_url_field(map, key, url, base_url)
_ ->
map
end
end
defp resolve_url_field(map, key, url, base_url) do
if String.starts_with?(url, ["http://", "https://"]) do
map
else
Map.put(map, key, "#{base_url}#{path_under_base(url)}")
end
end
defp path_under_base(url) do
if String.starts_with?(url, "/"), do: url, else: "/#{url}"
end
end

34
lib/gc_index_relay_web/router.ex

@ -32,25 +32,36 @@ defmodule GcIndexRelayWeb.Router do @@ -32,25 +32,36 @@ defmodule GcIndexRelayWeb.Router do
end
def swagger_info do
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info)
title = Keyword.fetch!(relay_info, :name)
version = Keyword.fetch!(relay_info, :version) |> to_string()
base_desc = Keyword.fetch!(relay_info, :description)
description =
base_desc <>
supported_nips_swagger_suffix(Keyword.get(relay_info, :supported_nips, [])) <>
"\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`."
%{
# `wss` reserved for a future NIP-01 WebSocket endpoint; REST-only for now.
schemes: ["https", "wss"],
info: %{
version: Application.spec(:gc_index_relay, :vsn) |> to_string(),
title: "Mercury Index-Relay",
description: """
A Nostr index relay by [GitCitadel](https://gitcitadel.eu). \
Stores and serves Nostr events via a REST API with NIP-01 filter-based querying.
**Supported NIPs:** NIP-11 (relay info), NIP-70 (protected events)
Relay information document available at `GET /` with `Accept: application/nostr+json`.
"""
version: version,
title: title,
description: description
},
consumes: ["application/json"],
produces: ["application/json"]
}
end
defp supported_nips_swagger_suffix([]), do: ""
defp supported_nips_swagger_suffix(nips) do
"\n\n**Supported NIPs:** " <> Enum.map_join(nips, ", ", &"NIP-#{&1}")
end
scope "/api/swagger" do
forward "/", PhoenixSwagger.Plug.SwaggerUI,
otp_app: :gc_index_relay,
@ -62,7 +73,7 @@ defmodule GcIndexRelayWeb.Router do @@ -62,7 +73,7 @@ defmodule GcIndexRelayWeb.Router do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
# Enable LiveDashboard in development
if Application.compile_env(:gc_index_relay, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
@ -75,7 +86,6 @@ defmodule GcIndexRelayWeb.Router do @@ -75,7 +86,6 @@ defmodule GcIndexRelayWeb.Router do
pipe_through :browser
live_dashboard "/dashboard", metrics: GcIndexRelayWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

39
lib/mix/tasks/test/integration.ex

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
defmodule Mix.Tasks.Test.Integration do
use Mix.Task
@shortdoc "Runs integration tests with REQUIRE_DB=true (requires PostgreSQL)"
@moduledoc """
Creates the test database if needed, runs migrations, then runs tests tagged `:integration`.
Spawns a subprocess with `REQUIRE_DB=true` so the application starts `GcIndexRelay.Repo`
under `MIX_ENV=test`. Extra args are forwarded to `mix test` (e.g. `--failed`, `--trace` for line-by-line output).
"""
@impl Mix.Task
def run(args) do
Mix.Task.run("loadconfig")
argv =
[
"REQUIRE_DB=true",
"MIX_ENV=test",
"mix",
"do",
"ecto.create",
"--quiet",
"+",
"ecto.migrate",
"--quiet",
"+",
"test",
"--only",
"integration"
] ++ args
{_output, exit_code} =
System.cmd("env", argv, into: IO.stream(:stdio, :line), cd: File.cwd!())
System.halt(exit_code)
end
end

17
mix.exs

@ -30,7 +30,8 @@ defmodule GcIndexRelay.MixProject do @@ -30,7 +30,8 @@ defmodule GcIndexRelay.MixProject do
preferred_envs: [
precommit: :test,
"test.unit": :test,
"test.integration": :test
"test.integration": :test,
"test.complete": :test
]
]
end
@ -53,13 +54,10 @@ defmodule GcIndexRelay.MixProject do @@ -53,13 +54,10 @@ defmodule GcIndexRelay.MixProject do
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:lib_secp256k1, "~> 0.7.1"},
{:phoenix_swagger, "~> 0.8"},
@ -76,21 +74,18 @@ defmodule GcIndexRelay.MixProject do @@ -76,21 +74,18 @@ defmodule GcIndexRelay.MixProject do
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
"test.unit": ["test --only unit"],
"test.integration": [
"ecto.create --quiet",
"ecto.migrate --quiet",
"test --only integration --trace"
],
precommit: [
"compile --warnings-as-errors",
"deps.unlock --unused",
"format",
"credo",
"test.unit"
]
],
# Full local CI: precommit (compile, format, credo, unit) then integration tests (needs PostgreSQL).
"test.complete": ["precommit", "test.integration"]
]
end
end

9
mix.lock

@ -5,24 +5,18 @@ @@ -5,24 +5,18 @@
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@ -35,13 +29,10 @@ @@ -35,13 +29,10 @@
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

11
priv/repo/seeds.exs

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# GcIndexRelay.Repo.insert!(%GcIndexRelay.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

22
priv/static/swagger.json

@ -1,19 +1,11 @@ @@ -1,19 +1,11 @@
{
"info": {
"version": "0.2.0",
"description": "A Nostr index relay by [GitCitadel](https://gitcitadel.eu). Stores and serves Nostr events via a REST API with NIP-01 filter-based querying.\n\n**Supported NIPs:** NIP-11 (relay info), NIP-70 (protected events)\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`.\n",
"description": "A Nostr index relay for the http protocol, from GitCitadel. Featuring a RESTful API and Swagger, it specializes in swift retrieval or publications, repos, and similar graphs of related events\n\n**Supported NIPs:** NIP-11, NIP-70\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`.",
"title": "Mercury Index-Relay"
},
"host": "localhost:4000",
"definitions": {
"PubEventList": {
"description": "A list of Nostr events",
"items": {
"$ref": "#/definitions/PubEvent"
},
"title": "PubEventList",
"type": "array"
},
"PubEvent": {
"description": "A signed Nostr event",
"example": {
@ -72,6 +64,14 @@ @@ -72,6 +64,14 @@
],
"title": "PubEvent",
"type": "object"
},
"PubEventList": {
"description": "A list of Nostr events",
"items": {
"$ref": "#/definitions/PubEvent"
},
"title": "PubEventList",
"type": "array"
}
},
"schemes": [
@ -228,10 +228,10 @@ @@ -228,10 +228,10 @@
}
},
"swagger": "2.0",
"consumes": [
"produces": [
"application/json"
],
"produces": [
"consumes": [
"application/json"
]
}

13
test/gc_index_relay/nostr/filter_test.exs

@ -27,11 +27,22 @@ defmodule GcIndexRelay.Nostr.FilterTest do @@ -27,11 +27,22 @@ defmodule GcIndexRelay.Nostr.FilterTest do
assert filter.tags == %{"e" => ["value1", "value2"]}
end
test "returns {:ok, filter} with valid tags (uppercase)" do
test "returns {:ok, filter} with valid tags (uppercase letter is distinct from lowercase)" do
assert {:ok, filter} = Filter.from_map(%{"#E" => ["value1"]})
assert filter.tags == %{"E" => ["value1"]}
end
test "returns {:ok, filter} with both #e and #E as separate tag filters" do
assert {:ok, filter} =
Filter.from_map(%{
"#e" => ["lowercase-scope"],
"#E" => ["uppercase-scope"],
"limit" => 10
})
assert filter.tags == %{"e" => ["lowercase-scope"], "E" => ["uppercase-scope"]}
end
test "returns {:ok, filter} with multiple valid tags" do
assert {:ok, filter} = Filter.from_map(%{"#e" => ["val1"], "#p" => ["val2"]})
assert filter.tags == %{"e" => ["val1"], "p" => ["val2"]}

65
test/gc_index_relay/nostr/pub_event_test.exs

@ -12,7 +12,7 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -12,7 +12,7 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
describe "to_db/1" do
test "converts tags with name and value" do
pub_event = valid_pub_event_fixture(tags: [["e", "abc123"], ["p", "def456"]])
event = PubEvent.to_db(pub_event)
assert {:ok, event} = PubEvent.to_db(pub_event)
assert [tag1, tag2] = event.tags
assert %Tag{name: "e", value: "abc123", additional_values: []} = tag1
@ -22,16 +22,33 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -22,16 +22,33 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "converts tags with additional values" do
tags = [["p", "pubkey_hex", "wss://relay.example.com", "alice"]]
pub_event = valid_pub_event_fixture(tags: tags)
event = PubEvent.to_db(pub_event)
assert {:ok, event} = PubEvent.to_db(pub_event)
assert [tag] = event.tags
assert %Tag{name: "p", value: "pubkey_hex"} = tag
assert tag.additional_values == ["wss://relay.example.com", "alice"]
end
test "maps reference kind 1111 tags including uppercase names and extras" do
pub_event = reference_kind1111_pub_event()
assert {:ok, event} = PubEvent.to_db(pub_event)
assert length(event.tags) == 7
e_upper = Enum.find(event.tags, &(&1.name == "E"))
assert e_upper.value == "6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca"
assert e_upper.additional_values == [
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
]
assert %Tag{name: "client", value: "imwald", additional_values: []} in event.tags
end
test "preserves empty content" do
pub_event = valid_pub_event_fixture(content: "")
event = PubEvent.to_db(pub_event)
assert {:ok, event} = PubEvent.to_db(pub_event)
assert event.content == ""
end
@ -96,7 +113,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -96,7 +113,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "preserves all fields for a basic event" do
pub_event = valid_pub_event_fixture(content: "round-trip test")
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.id == pub_event.id
assert result.pubkey == pub_event.pubkey
@ -110,7 +128,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -110,7 +128,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "preserves event with empty tags" do
pub_event = valid_pub_event_fixture(tags: [])
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == []
end
@ -123,7 +142,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -123,7 +142,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags
end
@ -132,7 +152,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -132,7 +152,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags
end
@ -145,14 +166,16 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -145,14 +166,16 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags
end
test "preserves event with empty content" do
pub_event = valid_pub_event_fixture(content: "")
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.content == ""
end
@ -160,8 +183,10 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -160,8 +183,10 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event_1 = valid_pub_event_fixture(keypair: :keypair1)
pub_event_2 = valid_pub_event_fixture(keypair: :keypair2)
assert {:ok, result_1} = pub_event_1 |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, result_2} = pub_event_2 |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db1} = PubEvent.to_db(pub_event_1)
assert {:ok, db2} = PubEvent.to_db(pub_event_2)
assert {:ok, result_1} = PubEvent.from_db(db1)
assert {:ok, result_2} = PubEvent.from_db(db2)
assert result_1.pubkey == pub_event_1.pubkey
assert result_2.pubkey == pub_event_2.pubkey
@ -170,31 +195,25 @@ defmodule GcIndexRelay.Nostr.PubEventTest do @@ -170,31 +195,25 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
end
describe "to_db/1 with invalid hex input" do
test "raises on invalid hex in id" do
test "returns error on invalid hex in id" do
pub_event = valid_pub_event_fixture()
invalid = %{pub_event | id: String.duplicate("zz", 32)}
assert_raise MatchError, fn ->
PubEvent.to_db(invalid)
end
assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
end
test "raises on invalid hex in pubkey" do
test "returns error on invalid hex in pubkey" do
pub_event = valid_pub_event_fixture()
invalid = %{pub_event | pubkey: String.duplicate("zz", 32)}
assert_raise MatchError, fn ->
PubEvent.to_db(invalid)
end
assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
end
test "raises on invalid hex in sig" do
test "returns error on invalid hex in sig" do
pub_event = valid_pub_event_fixture()
invalid = %{pub_event | sig: String.duplicate("zz", 64)}
assert_raise MatchError, fn ->
PubEvent.to_db(invalid)
end
assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
end
end
end

7
test/gc_index_relay/nostr/validator_test.exs

@ -175,5 +175,12 @@ defmodule GcIndexRelay.Nostr.ValidatorTest do @@ -175,5 +175,12 @@ defmodule GcIndexRelay.Nostr.ValidatorTest do
assert {:ok, ^event} = Validator.validate_id(event)
assert {:ok, ^event} = Validator.validate_signature(event)
end
test "validates real kind 1111 multi-tag reference event" do
event = reference_kind1111_pub_event()
assert {:ok, ^event} = Validator.validate_id(event)
assert {:ok, ^event} = Validator.validate_signature(event)
end
end
end

17
test/gc_index_relay/nostr_test.exs

@ -1,5 +1,22 @@ @@ -1,5 +1,22 @@
defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase
import GcIndexRelay.NostrFixtures
@moduletag :integration
describe "reference kind 1111 event" do
test "create_event and get_event round-trip all fields and tags" do
event = reference_kind1111_pub_event()
assert {:ok, _} = GcIndexRelay.Nostr.create_event(event)
assert {:ok, loaded} = GcIndexRelay.Nostr.get_event(event.id)
assert loaded.kind == event.kind
assert loaded.content == event.content
assert loaded.pubkey == event.pubkey
assert loaded.created_at == event.created_at
assert loaded.tags == event.tags
end
end
end

2
test/gc_index_relay_web/controllers/error_html_test.exs

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
defmodule GcIndexRelayWeb.ErrorHTMLTest do
use GcIndexRelayWeb.ConnCase, async: true
@moduletag :unit
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]

2
test/gc_index_relay_web/controllers/error_json_test.exs

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
defmodule GcIndexRelayWeb.ErrorJSONTest do
use GcIndexRelayWeb.ConnCase, async: true
@moduletag :unit
test "renders 404" do
assert GcIndexRelayWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end

46
test/gc_index_relay_web/controllers/filter_controller_test.exs

@ -80,6 +80,24 @@ defmodule GcIndexRelayWeb.FilterControllerTest do @@ -80,6 +80,24 @@ defmodule GcIndexRelayWeb.FilterControllerTest do
assert hd(events)["id"] == tagged_pub_event.id
end
test "filters by uppercase P tag separately from lowercase p (NIP-22)", %{conn: conn} do
upper = valid_pub_event_fixture(%{tags: [["P", "abc123def456"]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(upper)
lower = valid_pub_event_fixture(%{tags: [["p", "abc123def456"]], keypair: :keypair2})
{:ok, _} = GcIndexRelay.Nostr.create_event(lower)
event_fixture(%{kind: 2, created_at: 1_640_000_002})
conn_p = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&p=abc123def456")
assert %{"data" => p_events} = json_response(conn_p, 200)
assert Enum.map(p_events, & &1["id"]) == [lower.id]
conn_upper_p = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&P=abc123def456")
assert %{"data" => p_upper_events} = json_response(conn_upper_p, 200)
assert Enum.map(p_upper_events, & &1["id"]) == [upper.id]
end
test "respects limit parameter", %{conn: conn} do
event_fixture(%{kind: 1})
event_fixture(%{kind: 2, created_at: 1_640_000_001})
@ -239,6 +257,34 @@ defmodule GcIndexRelayWeb.FilterControllerTest do @@ -239,6 +257,34 @@ defmodule GcIndexRelayWeb.FilterControllerTest do
assert hd(events)["id"] == tagged_pub_event.id
end
test "POST filter #P matches uppercase P tag only, not lowercase p", %{conn: conn} do
upper = valid_pub_event_fixture(%{tags: [["P", "abc123def456"]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(upper)
lower = valid_pub_event_fixture(%{tags: [["p", "abc123def456"]], keypair: :keypair2})
{:ok, _} = GcIndexRelay.Nostr.create_event(lower)
event_fixture(%{kind: 2, created_at: 1_640_000_002})
conn_upper_p =
post(conn, ~p"/api/events/filter", %{
"#P" => ["abc123def456"],
"limit" => 10
})
assert %{"data" => p_upper} = json_response(conn_upper_p, 200)
assert Enum.map(p_upper, & &1["id"]) == [upper.id]
conn_p =
post(conn, ~p"/api/events/filter", %{
"#p" => ["abc123def456"],
"limit" => 10
})
assert %{"data" => p_lower} = json_response(conn_p, 200)
assert Enum.map(p_lower, & &1["id"]) == [lower.id]
end
test "respects limit parameter", %{conn: conn} do
event_fixture(%{kind: 1})
event_fixture(%{kind: 2, created_at: 1_640_000_001})

25
test/support/conn_case.ex

@ -32,7 +32,28 @@ defmodule GcIndexRelayWeb.ConnCase do @@ -32,7 +32,28 @@ defmodule GcIndexRelayWeb.ConnCase do
end
setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
sandbox_owner = GcIndexRelay.DataCase.setup_sandbox(tags)
conn =
Phoenix.ConnTest.build_conn()
|> maybe_put_sql_sandbox_user_agent(sandbox_owner)
{:ok, conn: conn}
end
defp maybe_put_sql_sandbox_user_agent(conn, nil), do: conn
defp maybe_put_sql_sandbox_user_agent(conn, owner) when is_pid(owner) do
if Application.get_env(:gc_index_relay, :sql_sandbox, false) do
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(GcIndexRelay.Repo, owner)
Plug.Conn.put_req_header(
conn,
"user-agent",
Phoenix.Ecto.SQL.Sandbox.encode_metadata(metadata)
)
else
conn
end
end
end

24
test/support/data_case.ex

@ -28,21 +28,35 @@ defmodule GcIndexRelay.DataCase do @@ -28,21 +28,35 @@ defmodule GcIndexRelay.DataCase do
end
setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags)
_ = GcIndexRelay.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
Starts a sandbox owner for the Repo when it is started in this environment.
Returns the sandbox owner pid when the Repo is started, otherwise `nil`.
"""
def setup_sandbox(tags) do
# Only set up sandbox if Repo was started
@spec setup_sandbox(map()) :: pid() | nil
def setup_sandbox(_tags) do
if Application.get_env(:gc_index_relay, :start_repo, true) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async])
# Always use shared: false so concurrent test *modules* (ExUnit default) do not
# clobber each other via `mode(repo, {:shared, _})`. The test process is allowed on
# the owner checkout; HTTP tests rely on `Phoenix.Ecto.SQL.Sandbox` + metadata header.
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: false)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
empty_nostr_event_tables!(GcIndexRelay.Repo)
pid
end
end
defp empty_nostr_event_tables!(repo) do
alias GcIndexRelay.Nostr.{Event, Tag}
_ = repo.delete_all(Tag)
_ = repo.delete_all(Event)
end
@doc """
A helper that transforms changeset errors into a map of messages.

36
test/support/fixtures/nostr_fixtures.ex

@ -153,6 +153,42 @@ defmodule GcIndexRelay.NostrFixtures do @@ -153,6 +153,42 @@ defmodule GcIndexRelay.NostrFixtures do
})
end
@doc """
Returns a real kind `1111` note with many tags (`E`/`P`/`K`/`e`/`k`/`p`/`client`) and a valid NIP-01 id and Schnorr signature.
Use for tag-parsing, filter, and crypto regression tests. Captured from the network as a known-good vector.
"""
def reference_kind1111_pub_event do
%PubEvent{
id: "ec42b22bcf5e9b7849ee951e0b55c8c9c1467ac694c8087eabf2bf0a7f6c035f",
pubkey: "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319",
created_at: 1_775_829_401,
kind: 1111,
content: "Ganz brav komprimiert, versprochen. Habe mir extra eine App dafür besorgt.",
sig:
"8f5323d967074351f459e562b66ebb7fc16b505b7dec6a979b6b50ce661f6c51e523215e2bc5afd048ed8b5914ba5e2f3ec4562032e971209579a6a9d2516c82",
tags: [
[
"E",
"6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca",
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
],
["P", "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"],
["K", "21"],
[
"e",
"6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca",
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
],
["k", "21"],
["p", "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"],
["client", "imwald"]
]
}
end
# Private helpers
defp compute_event_id(event) do

Loading…
Cancel
Save