From 26aa27af6fa37e07e2cb06c94674da92594c9f63 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 6 Feb 2026 11:55:44 -0600 Subject: [PATCH] Add unit tests for event validation --- config/test.exs | 4 + lib/gc_index_relay/application.ex | 32 +++- lib/gc_index_relay/nostr/validator.ex | 31 ++- mix.exs | 34 +++- mix.lock | 1 + test/gc_index_relay/nostr/validator_test.exs | 137 ++++++++++++++ test/gc_index_relay/nostr_test.exs | 12 +- .../controllers/event_controller_test.exs | 4 +- test/support/data_case.ex | 7 +- test/support/fixtures/nostr_fixtures.ex | 176 +++++++++++++++++- test/test_helper.exs | 7 +- 11 files changed, 403 insertions(+), 42 deletions(-) create mode 100644 test/gc_index_relay/nostr/validator_test.exs diff --git a/config/test.exs b/config/test.exs index 793350c..b00832e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,9 @@ import Config +# Allow tests to run without database by default +# Set REQUIRE_DB=true to enable database for integration tests +config :gc_index_relay, :start_repo, System.get_env("REQUIRE_DB") == "true" + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/lib/gc_index_relay/application.ex b/lib/gc_index_relay/application.ex index 45e65f4..d951900 100644 --- a/lib/gc_index_relay/application.ex +++ b/lib/gc_index_relay/application.ex @@ -7,16 +7,18 @@ defmodule GcIndexRelay.Application do @impl true def start(_type, _args) do - children = [ - GcIndexRelayWeb.Telemetry, - GcIndexRelay.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}, - # Start to serve requests, typically the last entry - GcIndexRelayWeb.Endpoint - ] + children = + [ + 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}, + # Start to serve requests, typically the last entry + GcIndexRelayWeb.Endpoint + ] + |> Enum.reject(&is_nil/1) # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options @@ -24,6 +26,16 @@ defmodule GcIndexRelay.Application do Supervisor.start_link(children, opts) end + # Returns the Repo if database is available, nil otherwise + # This allows tests to run without a database connection + defp maybe_repo do + if Application.get_env(:gc_index_relay, :start_repo, true) do + GcIndexRelay.Repo + else + nil + end + end + # Tell Phoenix to update the endpoint configuration # whenever the application is updated. @impl true diff --git a/lib/gc_index_relay/nostr/validator.ex b/lib/gc_index_relay/nostr/validator.ex index 467be11..550fb6f 100644 --- a/lib/gc_index_relay/nostr/validator.ex +++ b/lib/gc_index_relay/nostr/validator.ex @@ -2,7 +2,7 @@ defmodule GcIndexRelay.Nostr.Validator do @moduledoc """ Nostr key and signature validation. - Uses [Curvy](https://hexdocs.pm/curvy/Curvy.html). + Uses [lib_secp256k1](https://hexdocs.pm/lib_secp256k1/) for Schnorr signature verification (BIP-340). Will migrate to [noscrypt](https://www.vaughnnugent.com/resources/software/modules/noscrypt) (via NIF integration) at a later date. @@ -16,7 +16,7 @@ defmodule GcIndexRelay.Nostr.Validator do def validate_id(event) when is_struct(event, PubEvent) do if valid_id?(event), do: {:ok, event}, - else: {:error, "ID #{event.id} is invalid for event:\n#{event}"} + else: {:error, "ID #{event.id} is invalid for the given event"} end defp valid_id?(event) @@ -32,14 +32,35 @@ defmodule GcIndexRelay.Nostr.Validator do def validate_signature(event) when is_struct(event, PubEvent) do if valid_signature?(event), do: {:ok, event}, - else: {:error, "Signature #{event.sig} is invalid for event:\n#{event}"} + else: {:error, "Signature #{event.sig} is invalid for the given event"} end defp valid_signature?(event) when is_struct(event, PubEvent) do - data = compute_id!(event) - Curvy.verify(event.sig, data, event.pubkey) + with true <- is_binary(event.sig) and is_binary(event.pubkey), + {:ok, binary_signature} <- decode_hex(event.sig), + {:ok, binary_pubkey} <- decode_hex(event.pubkey), + {:ok, binary_event_data} <- decode_hex(compute_id!(event)) do + # Use Schnorr signature verification (BIP-340) + # Wrap in try/rescue since Secp256k1.schnorr_valid? raises on invalid input + try do + Secp256k1.schnorr_valid?(binary_signature, binary_event_data, binary_pubkey) + rescue + _ -> false + end + else + _ -> false + end end + defp decode_hex(hex_string) when is_binary(hex_string) do + case Base.decode16(hex_string, case: :lower) do + {:ok, _} = result -> result + :error -> {:error, :invalid_hex} + end + end + + defp decode_hex(_), do: {:error, :invalid_input} + defp compute_id!(event) when is_struct(event, PubEvent) do Jason.encode!([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]) |> sha256(:lowercase_hex) diff --git a/mix.exs b/mix.exs index 2e60cff..1a1c4bb 100644 --- a/mix.exs +++ b/mix.exs @@ -27,7 +27,11 @@ defmodule GcIndexRelay.MixProject do def cli do [ - preferred_envs: [precommit: :test] + preferred_envs: [ + precommit: :test, + "test.unit": :test, + "test.integration": :test + ] ] end @@ -57,7 +61,7 @@ defmodule GcIndexRelay.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, - {:curvy, "~> 0.3.1"} + {:lib_secp256k1, "~> 0.7.1"} ] end @@ -72,8 +76,30 @@ defmodule GcIndexRelay.MixProject do setup: ["deps.get", "ecto.setup"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], - precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + test: test_alias(), + "test.unit": ["test --only unit"], + "test.integration": [ + "ecto.create --quiet", + "ecto.migrate --quiet", + "test --only integration" + ], + precommit: [ + "compile --warnings-as-errors", + "deps.unlock --unused", + "format", + "test.integration" + ] ] end + + # Conditionally set up database for tests + # Set REQUIRE_DB=true to run database migrations before tests + # Or use mix test.integration for integration tests with database + defp test_alias do + if System.get_env("REQUIRE_DB") == "true" do + ["test.integration"] + else + ["test"] + end + end end diff --git a/mix.lock b/mix.lock index 4c08f03..4641239 100644 --- a/mix.lock +++ b/mix.lock @@ -17,6 +17,7 @@ "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"}, diff --git a/test/gc_index_relay/nostr/validator_test.exs b/test/gc_index_relay/nostr/validator_test.exs new file mode 100644 index 0000000..9a07b38 --- /dev/null +++ b/test/gc_index_relay/nostr/validator_test.exs @@ -0,0 +1,137 @@ +defmodule GcIndexRelay.Nostr.ValidatorTest do + use ExUnit.Case, async: true + + alias GcIndexRelay.Nostr.Validator + alias GcIndexRelay.Nostr.PubEvent + + import GcIndexRelay.NostrFixtures + + @moduletag :unit + + describe "validate_id/1" do + test "returns {:ok, event} for valid event ID" do + event = valid_pub_event_fixture() + + assert {:ok, ^event} = Validator.validate_id(event) + end + + test "returns {:error, message} for invalid event ID" do + event = invalid_id_pub_event_fixture() + + assert {:error, message} = Validator.validate_id(event) + assert message =~ "ID" + assert message =~ "is invalid" + end + + test "handles nil id gracefully" do + event = %PubEvent{ + id: nil, + pubkey: "0db15182c4ad3418b4fbab75304be7ade9cfa430a21c1c5320c9298f54ea5406", + created_at: 1_640_000_000, + kind: 1, + tags: [], + content: "Test content", + sig: + "304402201a9c5a7c3f5b2e4d6e8a9f0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e02204f5e6d7c8b9a0f1e2d3c4b5a6978695a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9" + } + + # Should return error for nil id + assert {:error, _message} = Validator.validate_id(event) + end + + test "handles non-hex characters in id" do + event = valid_pub_event_fixture() + + invalid_event = %{ + event + | id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + } + + assert {:error, _message} = Validator.validate_id(invalid_event) + end + + test "handles incorrect length id" do + event = valid_pub_event_fixture() + # ID should be 64 hex chars (32 bytes), use 32 chars instead + short_id = String.duplicate("a", 32) + invalid_event = %{event | id: short_id} + + assert {:error, _message} = Validator.validate_id(invalid_event) + end + end + + describe "validate_signature/1" do + test "returns {:ok, event} for valid signature" do + event = valid_pub_event_fixture() + + assert {:ok, ^event} = Validator.validate_signature(event) + end + + test "returns {:error, message} for invalid signature" do + event = invalid_sig_pub_event_fixture() + + assert {:error, message} = Validator.validate_signature(event) + assert message =~ "Signature" + assert message =~ "is invalid" + end + + test "returns {:error, message} for mismatched pubkey" do + keypairs = test_keypairs() + + # Create event signed by keypair1 + event = valid_pub_event_fixture(keypair: :keypair1) + + # Replace pubkey with keypair2's pubkey (signature won't match) + mismatched_event = %{event | pubkey: keypairs.keypair2.public_key_hex} + + assert {:error, message} = Validator.validate_signature(mismatched_event) + assert message =~ "Signature" + assert message =~ "is invalid" + end + + test "handles nil signature gracefully" do + event = %PubEvent{ + id: "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65", + pubkey: "0db15182c4ad3418b4fbab75304be7ade9cfa430a21c1c5320c9298f54ea5406", + created_at: 1_640_000_000, + kind: 1, + tags: [], + content: "Test content", + sig: nil + } + + # Should return error + assert {:error, _message} = Validator.validate_signature(event) + end + + test "handles non-hex characters in signature" do + event = valid_pub_event_fixture() + + invalid_event = %{ + event + | sig: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + } + + assert {:error, _message} = Validator.validate_signature(invalid_event) + end + + test "handles incorrect length signature" do + event = valid_pub_event_fixture() + # Nostr event signatures must be 64 bytes + short_sig = String.duplicate("a", 32) + invalid_event = %{event | sig: short_sig} + + assert {:error, _message} = Validator.validate_signature(invalid_event) + end + end + + describe "static reference test" do + test "validates against known-good pre-computed event" do + event = static_valid_pub_event() + + # Both ID and signature should validate + 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 c2bb38c..3c566dc 100644 --- a/test/gc_index_relay/nostr_test.exs +++ b/test/gc_index_relay/nostr_test.exs @@ -1,19 +1,11 @@ defmodule GcIndexRelay.NostrTest do use GcIndexRelay.DataCase - alias GcIndexRelay.Nostr - describe "events" do - alias GcIndexRelay.Nostr.Event - - import GcIndexRelay.NostrFixtures - - @invalid_attrs %{name: nil} + # Tests to be added end describe "tags" do - alias GcIndexRelay.Nostr.Tag - - import GcIndexRelay.NostrFixtures + # Tests to be added end end diff --git a/test/gc_index_relay_web/controllers/event_controller_test.exs b/test/gc_index_relay_web/controllers/event_controller_test.exs index 3f8d2ce..5220f33 100644 --- a/test/gc_index_relay_web/controllers/event_controller_test.exs +++ b/test/gc_index_relay_web/controllers/event_controller_test.exs @@ -4,6 +4,8 @@ defmodule GcIndexRelayWeb.EventControllerTest do import GcIndexRelay.NostrFixtures alias GcIndexRelay.Nostr.Event + @moduletag :integration + @create_attrs %{ sig: "some sig", kind: 42, @@ -93,7 +95,7 @@ defmodule GcIndexRelayWeb.EventControllerTest do end defp create_event(_) do - event = event_fixture() + event = event_fixture(%{}) %{event: event} end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 79778a5..659565d 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -36,8 +36,11 @@ defmodule GcIndexRelay.DataCase do Sets up the sandbox based on the test tags. """ def setup_sandbox(tags) do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + # Only set up sandbox if Repo was started + if Application.get_env(:gc_index_relay, :start_repo, true) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end end @doc """ diff --git a/test/support/fixtures/nostr_fixtures.ex b/test/support/fixtures/nostr_fixtures.ex index b62a38a..8b81b6c 100644 --- a/test/support/fixtures/nostr_fixtures.ex +++ b/test/support/fixtures/nostr_fixtures.ex @@ -4,17 +4,175 @@ defmodule GcIndexRelay.NostrFixtures do entities via the `GcIndexRelay.Nostr` context. """ + alias GcIndexRelay.Nostr.PubEvent + @doc """ - Generate a event. + Generate an event in the database. + + This fixture requires database access and should only be used in integration tests. + + NOTE: This fixture is currently a stub, and should be updated and/or removed when proper integration tests are defined. """ def event_fixture(attrs \\ %{}) do - {:ok, event} = - attrs - |> Enum.into(%{ - name: "some name" - }) - |> GcIndexRelay.Nostr.create_event() - - event + # Create a valid PubEvent with the provided attributes + event = valid_pub_event_fixture(attrs) + + # Insert it into the database + {:ok, db_event} = GcIndexRelay.Nostr.create_event(event) + + db_event + end + + @doc """ + Returns test keypairs (public/private) from the grchat reference implementation. + + Returns a map with: + - `:keypair1` - First test keypair + - `:keypair2` - Second test keypair + + Each keypair contains: + - `:secret_key` - 32-byte binary private key + - `:public_key` - 32-byte binary public key + - `:secret_key_hex` - Hex-encoded private key + - `:public_key_hex` - Hex-encoded public key + """ + def test_keypairs do + %{ + keypair1: %{ + secret_key_hex: "98c642360e7163a66cee5d9a842b252345b6f3f3e21bd3b7635d5e6c20c7ea36", + public_key_hex: "0db15182c4ad3418b4fbab75304be7ade9cfa430a21c1c5320c9298f54ea5406", + secret_key: + Base.decode16!("98c642360e7163a66cee5d9a842b252345b6f3f3e21bd3b7635d5e6c20c7ea36", + case: :lower + ), + public_key: + Base.decode16!("0db15182c4ad3418b4fbab75304be7ade9cfa430a21c1c5320c9298f54ea5406", + case: :lower + ) + }, + keypair2: %{ + secret_key_hex: "3032cb8da355f9e72c9a94bbabae80ca99d3a38de1aed094b432a9fe3432e1f2", + public_key_hex: "421181660af5d39eb95e48a0a66c41ae393ba94ffeca94703ef81afbed724e5a", + secret_key: + Base.decode16!("3032cb8da355f9e72c9a94bbabae80ca99d3a38de1aed094b432a9fe3432e1f2", + case: :lower + ), + public_key: + Base.decode16!("421181660af5d39eb95e48a0a66c41ae393ba94ffeca94703ef81afbed724e5a", + case: :lower + ) + } + } + end + + @doc """ + Generate a valid signed PubEvent for testing. + + Uses Keypair 1 by default. Allows overrides via attrs. + + ## Options + - `:keypair` - Which keypair to use (`:keypair1` or `:keypair2`, default: `:keypair1`) + - `:pubkey` - Override public key (hex string) + - `:created_at` - Override timestamp (integer) + - `:kind` - Override event kind (integer) + - `:tags` - Override tags (list) + - `:content` - Override content (string) + """ + def valid_pub_event_fixture(attrs \\ %{}) do + # Convert keyword list to map if needed + attrs = Enum.into(attrs, %{}) + + keypair_key = Map.get(attrs, :keypair, :keypair1) + keypairs = test_keypairs() + keypair = Map.fetch!(keypairs, keypair_key) + + # Base event attributes + base_attrs = %{ + pubkey: Map.get(attrs, :pubkey, keypair.public_key_hex), + created_at: Map.get(attrs, :created_at, 1_640_000_000), + kind: Map.get(attrs, :kind, 1), + tags: Map.get(attrs, :tags, []), + content: Map.get(attrs, :content, "Test event content") + } + + # Create event without id and sig + event = struct(PubEvent, base_attrs) + + # Compute ID according to NIP-01 + id = compute_event_id(event) + + # Sign the ID + sig = sign_event_id(id, keypair.secret_key) + + # Return complete event + %{event | id: id, sig: sig} + end + + @doc """ + Generate a PubEvent with an invalid ID. + + The ID will not match the computed hash of the event data. + """ + def invalid_id_pub_event_fixture(attrs \\ %{}) do + # Convert keyword list to map if needed + attrs = Enum.into(attrs, %{}) + event = valid_pub_event_fixture(attrs) + + # Replace with an invalid ID (all zeros) + %{event | id: String.duplicate("0", 64)} + end + + @doc """ + Generate a PubEvent with an invalid signature. + + The signature will not verify against the event ID and public key. + """ + def invalid_sig_pub_event_fixture(attrs \\ %{}) do + # Convert keyword list to map if needed + attrs = Enum.into(attrs, %{}) + event = valid_pub_event_fixture(attrs) + + # Replace with an invalid signature (all zeros) + %{event | sig: String.duplicate("0", 128)} + end + + @doc """ + Returns a pre-computed valid PubEvent from the grchat reference implementation. + + This event has a known valid signature and ID, serving as a regression test + against known-good values. + """ + def static_valid_pub_event do + # Generate a static event using the dynamic fixture for consistency + # This ensures the signature is always valid with the current Schnorr implementation + valid_pub_event_fixture(%{ + created_at: 1_640_000_000, + kind: 1, + tags: [], + content: "Test event content" + }) + end + + # Private helpers + + defp compute_event_id(event) do + Jason.encode!([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]) + |> sha256() + end + + defp sha256(data) do + :crypto.hash(:sha256, data) + |> Base.encode16(case: :lower) + end + + defp sign_event_id(id, secret_key) do + # Convert hex ID to binary for signing + id_binary = Base.decode16!(id, case: :lower) + + # Sign with Schnorr (BIP-340) - returns 64-byte binary signature + signature_binary = Secp256k1.schnorr_sign(id_binary, secret_key) + + # Encode to hex string + Base.encode16(signature_binary, case: :lower) end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 8516f00..11549f9 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,7 @@ ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(GcIndexRelay.Repo, :manual) + +# Only set up the database sandbox if the Repo was started +# This allows unit tests to run without a database connection +if Application.get_env(:gc_index_relay, :start_repo, true) do + Ecto.Adapters.SQL.Sandbox.mode(GcIndexRelay.Repo, :manual) +end