diff --git a/.env.example b/.env.example index d64d3c5..b20be0a 100644 --- a/.env.example +++ b/.env.example @@ -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 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)" diff --git a/.formatter.exs b/.formatter.exs index ef8840c..f9b487b 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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}"] ] diff --git a/README.md b/README.md index dde6406..b053035 100644 --- a/README.md +++ b/README.md @@ -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): 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 diff --git a/compose.yaml b/compose.yaml index bc04026..464659b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: 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: 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: 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: 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: diff --git a/config/config.exs b/config/config.exs index d08e411..f9c395d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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" => [ diff --git a/config/dev.exs b/config/dev.exs index cd093cf..2d660f8 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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, 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 diff --git a/config/prod.exs b/config/prod.exs index bd1c8f1..8c110d1 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index 2cf2104..4c14038 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 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 # 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 diff --git a/config/test.exs b/config/test.exs index 54bdbe8..6156591 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, 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 diff --git a/lib/gc_index_relay/application.ex b/lib/gc_index_relay/application.ex index d951900..9d5f5f2 100644 --- a/lib/gc_index_relay/application.ex +++ b/lib/gc_index_relay/application.ex @@ -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}, diff --git a/lib/gc_index_relay/mailer.ex b/lib/gc_index_relay/mailer.ex deleted file mode 100644 index e68acca..0000000 --- a/lib/gc_index_relay/mailer.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule GcIndexRelay.Mailer do - use Swoosh.Mailer, otp_app: :gc_index_relay -end diff --git a/lib/gc_index_relay/nostr.ex b/lib/gc_index_relay/nostr.ex index 2685cb0..03c44cd 100644 --- a/lib/gc_index_relay/nostr.ex +++ b/lib/gc_index_relay/nostr.ex @@ -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 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) diff --git a/lib/gc_index_relay/nostr/filter.ex b/lib/gc_index_relay/nostr/filter.ex index 4815f7b..5053137 100644 --- a/lib/gc_index_relay/nostr/filter.ex +++ b/lib/gc_index_relay/nostr/filter.ex @@ -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 @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 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} diff --git a/lib/gc_index_relay/nostr/pub_event.ex b/lib/gc_index_relay/nostr/pub_event.ex index 64038bf..4b12a9a 100644 --- a/lib/gc_index_relay/nostr/pub_event.ex +++ b/lib/gc_index_relay/nostr/pub_event.ex @@ -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 diff --git a/lib/gc_index_relay_web.ex b/lib/gc_index_relay_web.ex index 9fc27a3..8d742d9 100644 --- a/lib/gc_index_relay_web.ex +++ b/lib/gc_index_relay_web.ex @@ -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 diff --git a/lib/gc_index_relay_web/controllers/api_controller.ex b/lib/gc_index_relay_web/controllers/api_controller.ex index 4782397..1532d61 100644 --- a/lib/gc_index_relay_web/controllers/api_controller.ex +++ b/lib/gc_index_relay_web/controllers/api_controller.ex @@ -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", diff --git a/lib/gc_index_relay_web/controllers/event_controller.ex b/lib/gc_index_relay_web/controllers/event_controller.ex index bd5670c..d8397fa 100644 --- a/lib/gc_index_relay_web/controllers/event_controller.ex +++ b/lib/gc_index_relay_web/controllers/event_controller.ex @@ -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 diff --git a/lib/gc_index_relay_web/controllers/fallback_controller.ex b/lib/gc_index_relay_web/controllers/fallback_controller.ex index 0738f37..721b30a 100644 --- a/lib/gc_index_relay_web/controllers/fallback_controller.ex +++ b/lib/gc_index_relay_web/controllers/fallback_controller.ex @@ -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 _ -> 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 diff --git a/lib/gc_index_relay_web/controllers/filter_controller.ex b/lib/gc_index_relay_web/controllers/filter_controller.ex index 979b757..df0510a 100644 --- a/lib/gc_index_relay_web/controllers/filter_controller.ex +++ b/lib/gc_index_relay_web/controllers/filter_controller.ex @@ -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 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 # 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(<> = _key, value) when letter in ?a..?z do + defp parse_param(<> = _key, value) when letter in ?a..?z or letter in ?A..?Z do {:ok, String.split(value, ",")} end end diff --git a/lib/gc_index_relay_web/endpoint.ex b/lib/gc_index_relay_web/endpoint.ex index f123580..3704e4a 100644 --- a/lib/gc_index_relay_web/endpoint.ex +++ b/lib/gc_index_relay_web/endpoint.ex @@ -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 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 diff --git a/lib/gc_index_relay_web/plugs/cors.ex b/lib/gc_index_relay_web/plugs/cors.ex index 9a3a110..88aac8d 100644 --- a/lib/gc_index_relay_web/plugs/cors.ex +++ b/lib/gc_index_relay_web/plugs/cors.ex @@ -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 diff --git a/lib/gc_index_relay_web/plugs/relay_info.ex b/lib/gc_index_relay_web/plugs/relay_info.ex index 74e2748..d13a79c 100644 --- a/lib/gc_index_relay_web/plugs/relay_info.ex +++ b/lib/gc_index_relay_web/plugs/relay_info.ex @@ -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 diff --git a/lib/gc_index_relay_web/router.ex b/lib/gc_index_relay_web/router.ex index b1049a3..2017efc 100644 --- a/lib/gc_index_relay_web/router.ex +++ b/lib/gc_index_relay_web/router.ex @@ -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 # 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 pipe_through :browser live_dashboard "/dashboard", metrics: GcIndexRelayWeb.Telemetry - forward "/mailbox", Plug.Swoosh.MailboxPreview end end end diff --git a/lib/mix/tasks/test/integration.ex b/lib/mix/tasks/test/integration.ex new file mode 100644 index 0000000..d8b774f --- /dev/null +++ b/lib/mix/tasks/test/integration.ex @@ -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 diff --git a/mix.exs b/mix.exs index 75ed626..62e3a68 100644 --- a/mix.exs +++ b/mix.exs @@ -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 {: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 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 diff --git a/mix.lock b/mix.lock index e7fbfd1..b22cf02 100644 --- a/mix.lock +++ b/mix.lock @@ -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 @@ "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"}, } diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs deleted file mode 100644 index fa270c1..0000000 --- a/priv/repo/seeds.exs +++ /dev/null @@ -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. diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 1117b3c..1e0c16a 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -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 @@ ], "title": "PubEvent", "type": "object" + }, + "PubEventList": { + "description": "A list of Nostr events", + "items": { + "$ref": "#/definitions/PubEvent" + }, + "title": "PubEventList", + "type": "array" } }, "schemes": [ @@ -228,10 +228,10 @@ } }, "swagger": "2.0", - "consumes": [ + "produces": [ "application/json" ], - "produces": [ + "consumes": [ "application/json" ] } \ No newline at end of file diff --git a/test/gc_index_relay/nostr/filter_test.exs b/test/gc_index_relay/nostr/filter_test.exs index d1478ef..40147cf 100644 --- a/test/gc_index_relay/nostr/filter_test.exs +++ b/test/gc_index_relay/nostr/filter_test.exs @@ -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"]} diff --git a/test/gc_index_relay/nostr/pub_event_test.exs b/test/gc_index_relay/nostr/pub_event_test.exs index 7ab9f7e..893b894 100644 --- a/test/gc_index_relay/nostr/pub_event_test.exs +++ b/test/gc_index_relay/nostr/pub_event_test.exs @@ -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 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 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 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 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 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 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 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 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 diff --git a/test/gc_index_relay/nostr/validator_test.exs b/test/gc_index_relay/nostr/validator_test.exs index a05bb70..2636730 100644 --- a/test/gc_index_relay/nostr/validator_test.exs +++ b/test/gc_index_relay/nostr/validator_test.exs @@ -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 diff --git a/test/gc_index_relay/nostr_test.exs b/test/gc_index_relay/nostr_test.exs index 665bfb1..27b0e2e 100644 --- a/test/gc_index_relay/nostr_test.exs +++ b/test/gc_index_relay/nostr_test.exs @@ -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 diff --git a/test/gc_index_relay_web/controllers/error_html_test.exs b/test/gc_index_relay_web/controllers/error_html_test.exs index c9f9cb1..fb78cb7 100644 --- a/test/gc_index_relay_web/controllers/error_html_test.exs +++ b/test/gc_index_relay_web/controllers/error_html_test.exs @@ -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] diff --git a/test/gc_index_relay_web/controllers/error_json_test.exs b/test/gc_index_relay_web/controllers/error_json_test.exs index a1a2a2c..f665214 100644 --- a/test/gc_index_relay_web/controllers/error_json_test.exs +++ b/test/gc_index_relay_web/controllers/error_json_test.exs @@ -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 diff --git a/test/gc_index_relay_web/controllers/filter_controller_test.exs b/test/gc_index_relay_web/controllers/filter_controller_test.exs index 8b66944..e7e1aa5 100644 --- a/test/gc_index_relay_web/controllers/filter_controller_test.exs +++ b/test/gc_index_relay_web/controllers/filter_controller_test.exs @@ -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 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}) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 3d9a99d..91c2b61 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 659565d..666b19f 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -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. diff --git a/test/support/fixtures/nostr_fixtures.ex b/test/support/fixtures/nostr_fixtures.ex index 8b81b6c..c0b8b02 100644 --- a/test/support/fixtures/nostr_fixtures.ex +++ b/test/support/fixtures/nostr_fixtures.ex @@ -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