Browse Source

Add unit tests for event validation

master
buttercat1791 3 months ago
parent
commit
26aa27af6f
  1. 4
      config/test.exs
  2. 32
      lib/gc_index_relay/application.ex
  3. 31
      lib/gc_index_relay/nostr/validator.ex
  4. 34
      mix.exs
  5. 1
      mix.lock
  6. 137
      test/gc_index_relay/nostr/validator_test.exs
  7. 12
      test/gc_index_relay/nostr_test.exs
  8. 4
      test/gc_index_relay_web/controllers/event_controller_test.exs
  9. 7
      test/support/data_case.ex
  10. 176
      test/support/fixtures/nostr_fixtures.ex
  11. 7
      test/test_helper.exs

4
config/test.exs

@ -1,5 +1,9 @@ @@ -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

32
lib/gc_index_relay/application.ex

@ -7,16 +7,18 @@ defmodule GcIndexRelay.Application do @@ -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 @@ -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

31
lib/gc_index_relay/nostr/validator.ex

@ -2,7 +2,7 @@ defmodule GcIndexRelay.Nostr.Validator do @@ -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 @@ -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 @@ -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)

34
mix.exs

@ -27,7 +27,11 @@ defmodule GcIndexRelay.MixProject do @@ -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 @@ -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 @@ -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

1
mix.lock

@ -17,6 +17,7 @@ @@ -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"},

137
test/gc_index_relay/nostr/validator_test.exs

@ -0,0 +1,137 @@ @@ -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

12
test/gc_index_relay/nostr_test.exs

@ -1,19 +1,11 @@ @@ -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

4
test/gc_index_relay_web/controllers/event_controller_test.exs

@ -4,6 +4,8 @@ defmodule GcIndexRelayWeb.EventControllerTest do @@ -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 @@ -93,7 +95,7 @@ defmodule GcIndexRelayWeb.EventControllerTest do
end
defp create_event(_) do
event = event_fixture()
event = event_fixture(%{})
%{event: event}
end

7
test/support/data_case.ex

@ -36,8 +36,11 @@ defmodule GcIndexRelay.DataCase do @@ -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 """

176
test/support/fixtures/nostr_fixtures.ex

@ -4,17 +4,175 @@ defmodule GcIndexRelay.NostrFixtures do @@ -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

7
test/test_helper.exs

@ -1,2 +1,7 @@ @@ -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

Loading…
Cancel
Save