Implement relay auth, management, lifecycle and hardening phases
This commit is contained in:
@@ -16,5 +16,8 @@ defmodule Parrhesia.ApplicationTest do
|
||||
modules} ->
|
||||
is_pid(pid) and modules == [Bandit]
|
||||
end)
|
||||
|
||||
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
|
||||
assert is_pid(Process.whereis(Parrhesia.Negentropy.Sessions))
|
||||
end
|
||||
end
|
||||
|
||||
20
test/parrhesia/auth/challenges_test.exs
Normal file
20
test/parrhesia/auth/challenges_test.exs
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule Parrhesia.Auth.ChallengesTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Auth.Challenges
|
||||
|
||||
test "issues, validates and clears connection-scoped challenges" do
|
||||
server = start_supervised!({Challenges, name: nil})
|
||||
|
||||
challenge = Challenges.issue(server, self())
|
||||
assert is_binary(challenge)
|
||||
|
||||
assert Challenges.current(server, self()) == challenge
|
||||
assert Challenges.valid?(server, self(), challenge)
|
||||
|
||||
refute Challenges.valid?(server, self(), "wrong")
|
||||
|
||||
assert :ok = Challenges.clear(server, self())
|
||||
assert Challenges.current(server, self()) == nil
|
||||
end
|
||||
end
|
||||
42
test/parrhesia/auth/nip98_test.exs
Normal file
42
test/parrhesia/auth/nip98_test.exs
Normal file
@@ -0,0 +1,42 @@
|
||||
defmodule Parrhesia.Auth.Nip98Test do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Auth.Nip98
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
test "validates authorization header with matching method and url tags" do
|
||||
url = "http://example.com/management"
|
||||
event = nip98_event("POST", url)
|
||||
header = "Nostr " <> Base.encode64(Jason.encode!(event))
|
||||
|
||||
assert {:ok, parsed_event} = Nip98.validate_authorization_header(header, "POST", url)
|
||||
assert parsed_event["id"] == event["id"]
|
||||
end
|
||||
|
||||
test "rejects mismatched method and url" do
|
||||
url = "http://example.com/management"
|
||||
event = nip98_event("POST", url)
|
||||
header = "Nostr " <> Base.encode64(Jason.encode!(event))
|
||||
|
||||
assert {:error, :invalid_method_tag} =
|
||||
Nip98.validate_authorization_header(header, "GET", url)
|
||||
|
||||
assert {:error, :invalid_url_tag} =
|
||||
Nip98.validate_authorization_header(header, "POST", "http://example.com/other")
|
||||
end
|
||||
|
||||
defp nip98_event(method, url) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 27_235,
|
||||
"tags" => [["method", method], ["u", url]],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("b", 128)
|
||||
}
|
||||
|
||||
Map.put(base, "id", EventValidator.compute_id(base))
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,9 @@ defmodule Parrhesia.ConfigTest do
|
||||
assert Parrhesia.Config.get([:limits, :max_event_bytes]) == 262_144
|
||||
assert Parrhesia.Config.get([:limits, :max_event_future_skew_seconds]) == 900
|
||||
assert Parrhesia.Config.get([:limits, :max_outbound_queue]) == 256
|
||||
assert Parrhesia.Config.get([:limits, :max_filter_limit]) == 500
|
||||
assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false
|
||||
assert Parrhesia.Config.get([:features, :nip_50_search]) == true
|
||||
assert Parrhesia.Config.get([:features, :nip_ee_mls]) == false
|
||||
end
|
||||
|
||||
|
||||
25
test/parrhesia/fanout/multi_node_test.exs
Normal file
25
test/parrhesia/fanout/multi_node_test.exs
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule Parrhesia.Fanout.MultiNodeTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Fanout.MultiNode
|
||||
alias Parrhesia.Subscriptions.Index
|
||||
|
||||
test "publishes remote fanout events across pg members" do
|
||||
remote_bus = start_supervised!({MultiNode, name: nil})
|
||||
|
||||
assert :ok = Index.upsert(Index, self(), "sub-multi", [%{"kinds" => [1]}])
|
||||
|
||||
event = %{
|
||||
"id" => String.duplicate("a", 64),
|
||||
"kind" => 1,
|
||||
"pubkey" => String.duplicate("b", 64),
|
||||
"tags" => [],
|
||||
"content" => "x"
|
||||
}
|
||||
|
||||
assert :ok = MultiNode.publish(MultiNode, event)
|
||||
|
||||
assert_receive {:fanout_event, "sub-multi", ^event}
|
||||
assert is_pid(remote_bus)
|
||||
end
|
||||
end
|
||||
62
test/parrhesia/fault_injection_test.exs
Normal file
62
test/parrhesia/fault_injection_test.exs
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule Parrhesia.FaultInjectionTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
alias Parrhesia.TestSupport.FailingEvents
|
||||
alias Parrhesia.TestSupport.PermissiveModeration
|
||||
|
||||
setup do
|
||||
previous_storage = Application.get_env(:parrhesia, :storage, [])
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:storage,
|
||||
previous_storage
|
||||
|> Keyword.put(:events, FailingEvents)
|
||||
|> Keyword.put(:moderation, PermissiveModeration)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :storage, previous_storage)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "EVENT responds with error prefix when storage is unavailable" do
|
||||
{:ok, state} = Connection.init(subscription_index: nil)
|
||||
event = valid_event()
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({Jason.encode!(["EVENT", event]), [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == ["OK", event["id"], false, "error: :db_down"]
|
||||
end
|
||||
|
||||
test "REQ closes with storage error when query fails" do
|
||||
{:ok, state} = Connection.init(subscription_index: nil)
|
||||
payload = Jason.encode!(["REQ", "sub-db-down", %{"kinds" => [1]}])
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == ["CLOSED", "sub-db-down", "error: :db_down"]
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "fault",
|
||||
"sig" => String.duplicate("2", 128)
|
||||
}
|
||||
|
||||
Map.put(base, "id", EventValidator.compute_id(base))
|
||||
end
|
||||
end
|
||||
34
test/parrhesia/groups/flow_test.exs
Normal file
34
test/parrhesia/groups/flow_test.exs
Normal file
@@ -0,0 +1,34 @@
|
||||
defmodule Parrhesia.Groups.FlowTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Storage
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "handles membership request kinds by upserting group memberships" do
|
||||
event = %{
|
||||
"kind" => 8_000,
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"tags" => [["h", "group-1"]]
|
||||
}
|
||||
|
||||
assert :ok = Flow.handle_event(event)
|
||||
|
||||
assert {:ok, membership} =
|
||||
Storage.groups().get_membership(%{}, "group-1", String.duplicate("a", 64))
|
||||
|
||||
assert membership.role == "requested"
|
||||
end
|
||||
|
||||
test "marks configured membership and relay kinds as group related" do
|
||||
assert Flow.group_related_kind?(8_000)
|
||||
assert Flow.group_related_kind?(13_534)
|
||||
refute Flow.group_related_kind?(1)
|
||||
end
|
||||
end
|
||||
18
test/parrhesia/negentropy/sessions_test.exs
Normal file
18
test/parrhesia/negentropy/sessions_test.exs
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule Parrhesia.Negentropy.SessionsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Negentropy.Sessions
|
||||
|
||||
test "opens, advances and closes sessions" do
|
||||
server = start_supervised!({Sessions, name: nil})
|
||||
|
||||
assert {:ok, %{"status" => "open", "cursor" => 0}} =
|
||||
Sessions.open(server, self(), "sub-neg", %{"cursor" => 0})
|
||||
|
||||
assert {:ok, %{"status" => "ack", "cursor" => 1}} =
|
||||
Sessions.message(server, self(), "sub-neg", %{"delta" => "abc"})
|
||||
|
||||
assert :ok = Sessions.close(server, self(), "sub-neg")
|
||||
assert {:error, :unknown_session} = Sessions.message(server, self(), "sub-neg", %{})
|
||||
end
|
||||
end
|
||||
48
test/parrhesia/performance/load_soak_test.exs
Normal file
48
test/parrhesia/performance/load_soak_test.exs
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Parrhesia.Performance.LoadSoakTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
@tag :performance
|
||||
test "fanout enqueue/drain stays within relaxed p95 budget" do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
|
||||
{:ok, state} = Connection.init(subscription_index: nil, max_outbound_queue: 10_000)
|
||||
|
||||
req_payload = Jason.encode!(["REQ", "sub-load", %{"kinds" => [1]}])
|
||||
|
||||
assert {:push, _frames, subscribed_state} =
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
|
||||
durations =
|
||||
for idx <- 1..200 do
|
||||
event = %{
|
||||
"id" => "event-#{idx}",
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "load",
|
||||
"sig" => String.duplicate("b", 128)
|
||||
}
|
||||
|
||||
started_at = System.monotonic_time()
|
||||
|
||||
assert {:ok, _queued_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-load", event}, subscribed_state)
|
||||
|
||||
System.convert_time_unit(System.monotonic_time() - started_at, :native, :microsecond)
|
||||
end
|
||||
|
||||
p95 = percentile(durations, 95)
|
||||
assert p95 < 25_000
|
||||
end
|
||||
|
||||
defp percentile(values, percentile_rank) do
|
||||
sorted = Enum.sort(values)
|
||||
index = max(0, ceil(length(sorted) * percentile_rank / 100) - 1)
|
||||
Enum.at(sorted, index)
|
||||
end
|
||||
end
|
||||
82
test/parrhesia/policy/event_policy_test.exs
Normal file
82
test/parrhesia/policy/event_policy_test.exs
Normal file
@@ -0,0 +1,82 @@
|
||||
defmodule Parrhesia.Policy.EventPolicyTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Policy.EventPolicy
|
||||
|
||||
alias Parrhesia.TestSupport.PermissiveModeration
|
||||
|
||||
setup do
|
||||
previous_policies = Application.get_env(:parrhesia, :policies, [])
|
||||
previous_features = Application.get_env(:parrhesia, :features, [])
|
||||
previous_storage = Application.get_env(:parrhesia, :storage, [])
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:storage,
|
||||
Keyword.put(previous_storage, :moderation, PermissiveModeration)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :policies, previous_policies)
|
||||
Application.put_env(:parrhesia, :features, previous_features)
|
||||
Application.put_env(:parrhesia, :storage, previous_storage)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "requires auth for reads when configured" do
|
||||
Application.put_env(:parrhesia, :policies, auth_required_for_reads: true)
|
||||
|
||||
assert {:error, :auth_required} =
|
||||
EventPolicy.authorize_read([%{"kinds" => [1]}], MapSet.new())
|
||||
|
||||
assert :ok =
|
||||
EventPolicy.authorize_read(
|
||||
[%{"kinds" => [1]}],
|
||||
MapSet.new([String.duplicate("a", 64)])
|
||||
)
|
||||
end
|
||||
|
||||
test "restricts giftwrap reads without recipient auth" do
|
||||
filter = %{"kinds" => [1059], "#p" => [String.duplicate("b", 64)]}
|
||||
|
||||
assert {:error, :restricted_giftwrap} = EventPolicy.authorize_read([filter], MapSet.new())
|
||||
|
||||
assert :ok =
|
||||
EventPolicy.authorize_read([filter], MapSet.new([String.duplicate("b", 64)]))
|
||||
end
|
||||
|
||||
test "rejects protected events without auth" do
|
||||
event = %{"tags" => [["-"]], "pubkey" => String.duplicate("c", 64), "id" => ""}
|
||||
|
||||
assert {:error, :protected_event_requires_auth} =
|
||||
EventPolicy.authorize_write(event, MapSet.new())
|
||||
|
||||
assert :ok =
|
||||
EventPolicy.authorize_write(event, MapSet.new([String.duplicate("c", 64)]))
|
||||
end
|
||||
|
||||
test "rejects mls kinds when feature is disabled" do
|
||||
Application.put_env(:parrhesia, :features, nip_ee_mls: false)
|
||||
|
||||
event = %{"kind" => 443, "tags" => [], "pubkey" => String.duplicate("d", 64), "id" => ""}
|
||||
|
||||
assert {:error, :mls_disabled} =
|
||||
EventPolicy.authorize_write(event, MapSet.new([String.duplicate("d", 64)]))
|
||||
end
|
||||
|
||||
test "enforces min pow difficulty" do
|
||||
Application.put_env(:parrhesia, :policies, min_pow_difficulty: 8)
|
||||
|
||||
weak_pow_event = %{
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"pubkey" => String.duplicate("e", 64),
|
||||
"id" => "ff1234"
|
||||
}
|
||||
|
||||
assert {:error, :pow_below_minimum} =
|
||||
EventPolicy.authorize_write(weak_pow_event, MapSet.new([String.duplicate("e", 64)]))
|
||||
end
|
||||
end
|
||||
31
test/parrhesia/protocol/filter_property_test.exs
Normal file
31
test/parrhesia/protocol/filter_property_test.exs
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Parrhesia.Protocol.FilterPropertyTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Parrhesia.Protocol.Filter
|
||||
|
||||
property "author filter match is equivalent to membership in author list" do
|
||||
check all(
|
||||
author <- hex64(),
|
||||
candidate_authors <- list_of(hex64(), min_length: 1, max_length: 5),
|
||||
created_at <- StreamData.non_negative_integer()
|
||||
) do
|
||||
event = %{
|
||||
"pubkey" => author,
|
||||
"kind" => 1,
|
||||
"created_at" => created_at,
|
||||
"tags" => [],
|
||||
"content" => ""
|
||||
}
|
||||
|
||||
filter = %{"authors" => candidate_authors}
|
||||
|
||||
assert Filter.matches_filter?(event, filter) == author in candidate_authors
|
||||
end
|
||||
end
|
||||
|
||||
defp hex64 do
|
||||
StreamData.binary(length: 32)
|
||||
|> StreamData.map(&Base.encode16(&1, case: :lower))
|
||||
end
|
||||
end
|
||||
@@ -20,10 +20,11 @@ defmodule Parrhesia.Protocol.FilterTest do
|
||||
assert :ok = Filter.validate_filters(filters)
|
||||
end
|
||||
|
||||
test "rejects unsupported filter keys" do
|
||||
filters = [%{"search" => "hello"}]
|
||||
test "accepts search filter key and rejects unknown keys" do
|
||||
assert :ok = Filter.validate_filters([%{"search" => "hello"}])
|
||||
|
||||
assert {:error, :invalid_filter_key} = Filter.validate_filters(filters)
|
||||
assert {:error, :invalid_filter_key} =
|
||||
Filter.validate_filters([%{"unknown" => "value"}])
|
||||
|
||||
assert Filter.error_message(:invalid_filter_key) ==
|
||||
"invalid: filter contains unknown elements"
|
||||
@@ -36,6 +37,7 @@ defmodule Parrhesia.Protocol.FilterTest do
|
||||
Filter.validate_filters([%{"authors" => [String.duplicate("A", 64)]}])
|
||||
|
||||
assert {:error, :invalid_kinds} = Filter.validate_filters([%{"kinds" => ["1"]}])
|
||||
assert {:error, :invalid_search} = Filter.validate_filters([%{"search" => ""}])
|
||||
end
|
||||
|
||||
test "matches with AND semantics inside filter and OR across filters" do
|
||||
@@ -46,7 +48,8 @@ defmodule Parrhesia.Protocol.FilterTest do
|
||||
"kinds" => [event["kind"]],
|
||||
"#e" => ["ref-2"],
|
||||
"since" => event["created_at"],
|
||||
"until" => event["created_at"]
|
||||
"until" => event["created_at"],
|
||||
"search" => "HEL"
|
||||
}
|
||||
|
||||
non_matching_filter = %{"authors" => [String.duplicate("d", 64)]}
|
||||
|
||||
@@ -12,13 +12,17 @@ defmodule Parrhesia.ProtocolTest do
|
||||
assert event["content"] == "hello"
|
||||
end
|
||||
|
||||
test "decodes valid REQ and CLOSE frames" do
|
||||
test "decodes valid REQ, COUNT and CLOSE frames" do
|
||||
req_payload = Jason.encode!(["REQ", "sub-1", %{"authors" => [String.duplicate("a", 64)]}])
|
||||
count_payload = Jason.encode!(["COUNT", "sub-1", %{"kinds" => [1]}, %{"hll" => true}])
|
||||
close_payload = Jason.encode!(["CLOSE", "sub-1"])
|
||||
|
||||
assert {:ok, {:req, "sub-1", [%{"authors" => [_author]}]}} =
|
||||
Protocol.decode_client(req_payload)
|
||||
|
||||
assert {:ok, {:count, "sub-1", [%{"kinds" => [1]}], %{"hll" => true}}} =
|
||||
Protocol.decode_client(count_payload)
|
||||
|
||||
assert {:ok, {:close, "sub-1"}} = Protocol.decode_client(close_payload)
|
||||
end
|
||||
|
||||
@@ -30,9 +34,27 @@ defmodule Parrhesia.ProtocolTest do
|
||||
assert {:error, :invalid_subscription_id} = Protocol.decode_client(long_sub_payload)
|
||||
end
|
||||
|
||||
test "decodes AUTH and NEG frames" do
|
||||
auth_event = valid_event() |> Map.put("kind", 22_242) |> Map.put("content", "")
|
||||
auth_event = Map.put(auth_event, "id", EventValidator.compute_id(auth_event))
|
||||
|
||||
assert {:ok, {:auth, ^auth_event}} =
|
||||
Protocol.decode_client(Jason.encode!(["AUTH", auth_event]))
|
||||
|
||||
assert {:ok, {:neg_open, "sub-neg", %{"cursor" => 0}}} =
|
||||
Protocol.decode_client(Jason.encode!(["NEG-OPEN", "sub-neg", %{"cursor" => 0}]))
|
||||
|
||||
assert {:ok, {:neg_msg, "sub-neg", %{"delta" => "abc"}}} =
|
||||
Protocol.decode_client(Jason.encode!(["NEG-MSG", "sub-neg", %{"delta" => "abc"}]))
|
||||
|
||||
assert {:ok, {:neg_close, "sub-neg"}} =
|
||||
Protocol.decode_client(Jason.encode!(["NEG-CLOSE", "sub-neg"]))
|
||||
end
|
||||
|
||||
test "returns decode errors for malformed messages" do
|
||||
assert {:error, :invalid_json} = Protocol.decode_client("not-json")
|
||||
assert {:error, :invalid_filters} = Protocol.decode_client(Jason.encode!(["REQ", "sub-1"]))
|
||||
assert {:error, :invalid_count} = Protocol.decode_client(Jason.encode!(["COUNT", "sub-1"]))
|
||||
|
||||
assert {:error, :invalid_event} =
|
||||
Protocol.decode_client(Jason.encode!(["EVENT", "not-a-map"]))
|
||||
@@ -62,6 +84,12 @@ defmodule Parrhesia.ProtocolTest do
|
||||
test "encodes relay messages" do
|
||||
frame = Protocol.encode_relay({:closed, "sub-1", "error: subscription closed"})
|
||||
assert Jason.decode!(frame) == ["CLOSED", "sub-1", "error: subscription closed"]
|
||||
|
||||
auth_frame = Protocol.encode_relay({:auth, "challenge"})
|
||||
assert Jason.decode!(auth_frame) == ["AUTH", "challenge"]
|
||||
|
||||
count_frame = Protocol.encode_relay({:count, "sub-1", %{"count" => 1}})
|
||||
assert Jason.decode!(count_frame) == ["COUNT", "sub-1", %{"count" => 1}]
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
|
||||
28
test/parrhesia/storage/adapters/memory/adapter_test.exs
Normal file
28
test/parrhesia/storage/adapters/memory/adapter_test.exs
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Memory.AdapterTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Storage.Adapters.Memory.Admin
|
||||
alias Parrhesia.Storage.Adapters.Memory.Events
|
||||
alias Parrhesia.Storage.Adapters.Memory.Groups
|
||||
alias Parrhesia.Storage.Adapters.Memory.Moderation
|
||||
|
||||
test "memory adapter supports basic behavior contract operations" do
|
||||
event_id = String.duplicate("a", 64)
|
||||
event = %{"id" => event_id, "pubkey" => "pk", "kind" => 1, "tags" => [], "content" => "hello"}
|
||||
|
||||
assert {:ok, _event} = Events.put_event(%{}, event)
|
||||
assert {:ok, [result]} = Events.query(%{}, [%{"ids" => [event_id]}], [])
|
||||
assert result["id"] == event_id
|
||||
|
||||
assert :ok = Moderation.ban_pubkey(%{}, "pk")
|
||||
assert {:ok, true} = Moderation.pubkey_banned?(%{}, "pk")
|
||||
|
||||
assert {:ok, membership} =
|
||||
Groups.put_membership(%{}, %{group_id: "g1", pubkey: "pk", role: "member"})
|
||||
|
||||
assert membership.group_id == "g1"
|
||||
|
||||
assert :ok = Admin.append_audit_log(%{}, %{method: "ping"})
|
||||
assert {:ok, [%{method: "ping"}]} = Admin.list_audit_logs(%{}, [])
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,10 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
|
||||
alias Parrhesia.Storage.Adapters.Postgres.Moderation
|
||||
|
||||
setup_all do
|
||||
start_supervised!(Repo)
|
||||
if is_nil(Process.whereis(Repo)) do
|
||||
start_supervised!(Repo)
|
||||
end
|
||||
|
||||
Sandbox.mode(Repo, :manual)
|
||||
:ok
|
||||
end
|
||||
@@ -125,6 +128,11 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
|
||||
assert {:ok, stats_logs} = Admin.list_audit_logs(%{}, method: :stats)
|
||||
assert Enum.map(stats_logs, & &1.method) == ["stats"]
|
||||
|
||||
assert {:ok, %{"status" => "ok"}} = Admin.execute(%{}, :ping, %{})
|
||||
|
||||
assert {:ok, %{"events" => _events, "banned_pubkeys" => _banned, "blocked_ips" => _ips}} =
|
||||
Admin.execute(%{}, :stats, %{})
|
||||
|
||||
assert {:error, {:unsupported_method, "status"}} = Admin.execute(%{}, :status, %{})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Postgres.EventsLifecycleTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Storage.Adapters.Postgres.Events
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "delete_by_request tombstones owned target events" do
|
||||
target = event(%{"kind" => 1, "content" => "target"})
|
||||
assert {:ok, _event} = Events.put_event(%{}, target)
|
||||
|
||||
delete_request =
|
||||
event(%{
|
||||
"kind" => 5,
|
||||
"tags" => [["e", target["id"]]],
|
||||
"content" => "delete"
|
||||
})
|
||||
|
||||
assert {:ok, 1} = Events.delete_by_request(%{}, delete_request)
|
||||
assert {:ok, nil} = Events.get_event(%{}, target["id"])
|
||||
end
|
||||
|
||||
test "vanish hard-deletes events authored by pubkey" do
|
||||
author = String.duplicate("3", 64)
|
||||
|
||||
first_event = event(%{"pubkey" => author, "created_at" => 1_700_000_000})
|
||||
second_event = event(%{"pubkey" => author, "created_at" => 1_700_000_100})
|
||||
|
||||
assert {:ok, _event} = Events.put_event(%{}, first_event)
|
||||
assert {:ok, _event} = Events.put_event(%{}, second_event)
|
||||
|
||||
vanish_event =
|
||||
event(%{
|
||||
"pubkey" => author,
|
||||
"kind" => 62,
|
||||
"created_at" => 1_700_000_200,
|
||||
"content" => "vanish"
|
||||
})
|
||||
|
||||
assert {:ok, count} = Events.vanish(%{}, vanish_event)
|
||||
assert count >= 2
|
||||
|
||||
assert {:ok, nil} = Events.get_event(%{}, first_event["id"])
|
||||
assert {:ok, nil} = Events.get_event(%{}, second_event["id"])
|
||||
end
|
||||
|
||||
defp event(overrides) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "hello",
|
||||
"sig" => String.duplicate("2", 128)
|
||||
}
|
||||
|
||||
event = Map.merge(base, overrides)
|
||||
Map.put(event, "id", EventValidator.compute_id(event))
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,10 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do
|
||||
alias Parrhesia.Storage.Adapters.Postgres.Events
|
||||
|
||||
setup_all do
|
||||
start_supervised!(Repo)
|
||||
if is_nil(Process.whereis(Repo)) do
|
||||
start_supervised!(Repo)
|
||||
end
|
||||
|
||||
Sandbox.mode(Repo, :manual)
|
||||
:ok
|
||||
end
|
||||
@@ -217,6 +220,57 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do
|
||||
assert {:ok, 1} = Events.count(%{}, [%{"ids" => [first["id"], second["id"]]}], [])
|
||||
end
|
||||
|
||||
test "query/3 supports search filter and giftwrap recipient restriction" do
|
||||
recipient = String.duplicate("9", 64)
|
||||
|
||||
allowed =
|
||||
persist_event(%{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", recipient]],
|
||||
"content" => "encrypted hello to recipient"
|
||||
})
|
||||
|
||||
_other =
|
||||
persist_event(%{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", String.duplicate("1", 64)]],
|
||||
"content" => "encrypted hello to somebody else"
|
||||
})
|
||||
|
||||
filters = [%{"kinds" => [1059], "search" => "recipient"}]
|
||||
|
||||
assert {:ok, [result]} =
|
||||
Events.query(%{}, filters, requester_pubkeys: [recipient])
|
||||
|
||||
assert result["id"] == allowed["id"]
|
||||
end
|
||||
|
||||
test "mls keypackage relay list kind 10051 follows replaceable conflict semantics" do
|
||||
author = String.duplicate("c", 64)
|
||||
|
||||
first =
|
||||
persist_event(%{
|
||||
"pubkey" => author,
|
||||
"created_at" => 1_700_000_500,
|
||||
"kind" => 10_051,
|
||||
"content" => "v1"
|
||||
})
|
||||
|
||||
second =
|
||||
persist_event(%{
|
||||
"pubkey" => author,
|
||||
"created_at" => 1_700_000_501,
|
||||
"kind" => 10_051,
|
||||
"content" => "v2"
|
||||
})
|
||||
|
||||
assert {:ok, [result]} =
|
||||
Events.query(%{}, [%{"authors" => [author], "kinds" => [10_051]}], [])
|
||||
|
||||
assert result["id"] == second["id"]
|
||||
assert {:ok, nil} = Events.get_event(%{}, first["id"])
|
||||
end
|
||||
|
||||
defp persist_event(overrides) do
|
||||
event = build_event(overrides)
|
||||
assert {:ok, _persisted} = Events.put_event(%{}, event)
|
||||
|
||||
@@ -28,6 +28,37 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsTest do
|
||||
assert normalized.pubkey == Base.decode16!(pubkey, case: :lower)
|
||||
end
|
||||
|
||||
test "applies MLS retention TTL to kind 445 when enabled" do
|
||||
previous_features = Application.get_env(:parrhesia, :features, [])
|
||||
previous_policies = Application.get_env(:parrhesia, :policies, [])
|
||||
|
||||
Application.put_env(:parrhesia, :features, Keyword.put(previous_features, :nip_ee_mls, true))
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
Keyword.put(previous_policies, :mls_group_event_ttl_seconds, 120)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :features, previous_features)
|
||||
Application.put_env(:parrhesia, :policies, previous_policies)
|
||||
end)
|
||||
|
||||
event = %{
|
||||
"id" => String.duplicate("1", 64),
|
||||
"pubkey" => String.duplicate("2", 64),
|
||||
"created_at" => 1_700_000_000,
|
||||
"kind" => 445,
|
||||
"tags" => [],
|
||||
"content" => "mls",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
|
||||
assert {:ok, normalized} = Events.normalize_event(event)
|
||||
assert normalized.expires_at == 1_700_000_120
|
||||
end
|
||||
|
||||
test "candidate_wins_state?/2 uses created_at then lexical id tie-break" do
|
||||
assert Events.candidate_wins_state?(
|
||||
%{created_at: 11, id: <<2>>},
|
||||
|
||||
22
test/parrhesia/storage/archiver_test.exs
Normal file
22
test/parrhesia/storage/archiver_test.exs
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Parrhesia.Storage.ArchiverTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Storage.Archiver
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "list_partitions returns partition tables" do
|
||||
partitions = Archiver.list_partitions()
|
||||
assert is_list(partitions)
|
||||
end
|
||||
|
||||
test "archive_sql builds insert-select statement" do
|
||||
assert Archiver.archive_sql("events_2026_03", "events_archive") ==
|
||||
"INSERT INTO events_archive SELECT * FROM events_2026_03;"
|
||||
end
|
||||
end
|
||||
31
test/parrhesia/tasks/expiration_worker_test.exs
Normal file
31
test/parrhesia/tasks/expiration_worker_test.exs
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Parrhesia.Tasks.ExpirationWorkerTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Tasks.ExpirationWorker
|
||||
alias Parrhesia.TestSupport.ExpirationStubEvents
|
||||
|
||||
setup do
|
||||
previous_storage = Application.get_env(:parrhesia, :storage, [])
|
||||
:persistent_term.put({ExpirationStubEvents, :test_pid}, self())
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:storage,
|
||||
Keyword.put(previous_storage, :events, ExpirationStubEvents)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
:persistent_term.erase({ExpirationStubEvents, :test_pid})
|
||||
Application.put_env(:parrhesia, :storage, previous_storage)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "periodically triggers purge_expired" do
|
||||
worker = start_supervised!({ExpirationWorker, name: nil, interval_ms: 10})
|
||||
|
||||
assert is_pid(worker)
|
||||
assert_receive :purged
|
||||
end
|
||||
end
|
||||
59
test/parrhesia/web/conformance_test.exs
Normal file
59
test/parrhesia/web/conformance_test.exs
Normal file
@@ -0,0 +1,59 @@
|
||||
defmodule Parrhesia.Web.ConformanceTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "REQ -> EOSE emitted once and CLOSE emits CLOSED" do
|
||||
{:ok, state} = Connection.init(subscription_index: nil)
|
||||
|
||||
req_payload = Jason.encode!(["REQ", "sub-e2e", %{"kinds" => [1]}])
|
||||
|
||||
assert {:push, frames, subscribed_state} =
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
|
||||
decoded = Enum.map(frames, fn {:text, frame} -> Jason.decode!(frame) end)
|
||||
assert ["EOSE", "sub-e2e"] = List.last(decoded)
|
||||
|
||||
close_payload = Jason.encode!(["CLOSE", "sub-e2e"])
|
||||
|
||||
assert {:push, {:text, closed_frame}, closed_state} =
|
||||
Connection.handle_in({close_payload, [opcode: :text]}, subscribed_state)
|
||||
|
||||
assert Jason.decode!(closed_frame) == ["CLOSED", "sub-e2e", "error: subscription closed"]
|
||||
refute Map.has_key?(closed_state.subscriptions, "sub-e2e")
|
||||
end
|
||||
|
||||
test "EVENT accepted path returns canonical OK frame" do
|
||||
{:ok, state} = Connection.init(subscription_index: nil)
|
||||
|
||||
event = valid_event()
|
||||
|
||||
assert {:push, {:text, frame}, ^state} =
|
||||
Connection.handle_in({Jason.encode!(["EVENT", event]), [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(frame) == ["OK", event["id"], true, "ok: event stored"]
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "e2e",
|
||||
"sig" => String.duplicate("2", 128)
|
||||
}
|
||||
|
||||
Map.put(base, "id", EventValidator.compute_id(base))
|
||||
end
|
||||
end
|
||||
@@ -1,106 +1,100 @@
|
||||
defmodule Parrhesia.Web.ConnectionTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
test "REQ registers subscription and replies with EOSE" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "REQ registers subscription, streams initial events and replies with EOSE" do
|
||||
state = connection_state()
|
||||
|
||||
req_payload = Jason.encode!(["REQ", "sub-123", %{"kinds" => [1]}])
|
||||
|
||||
assert {:push, {:text, response}, next_state} =
|
||||
assert {:push, responses, next_state} =
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
|
||||
assert Map.has_key?(next_state.subscriptions, "sub-123")
|
||||
assert next_state.subscriptions["sub-123"].filters == [%{"kinds" => [1]}]
|
||||
assert next_state.subscriptions["sub-123"].eose_sent?
|
||||
assert Jason.decode!(response) == ["EOSE", "sub-123"]
|
||||
end
|
||||
|
||||
test "REQ with same subscription id replaces existing subscription" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
|
||||
first_req = Jason.encode!(["REQ", "sub-123", %{"kinds" => [1]}])
|
||||
second_req = Jason.encode!(["REQ", "sub-123", %{"kinds" => [2], "limit" => 5}])
|
||||
|
||||
assert {:push, _, subscribed_state} =
|
||||
Connection.handle_in({first_req, [opcode: :text]}, state)
|
||||
|
||||
assert {:push, {:text, response}, replaced_state} =
|
||||
Connection.handle_in({second_req, [opcode: :text]}, subscribed_state)
|
||||
|
||||
assert map_size(replaced_state.subscriptions) == 1
|
||||
|
||||
assert replaced_state.subscriptions["sub-123"].filters == [
|
||||
%{"kinds" => [2], "limit" => 5}
|
||||
assert List.last(Enum.map(responses, fn {:text, frame} -> Jason.decode!(frame) end)) == [
|
||||
"EOSE",
|
||||
"sub-123"
|
||||
]
|
||||
|
||||
assert Jason.decode!(response) == ["EOSE", "sub-123"]
|
||||
end
|
||||
|
||||
test "CLOSE removes subscription and replies with CLOSED" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
test "COUNT returns exact count payload" do
|
||||
state = connection_state()
|
||||
|
||||
req_payload = Jason.encode!(["REQ", "sub-123", %{"kinds" => [1]}])
|
||||
{:push, _, subscribed_state} = Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
payload = Jason.encode!(["COUNT", "sub-count", %{"kinds" => [1]}])
|
||||
|
||||
close_payload = Jason.encode!(["CLOSE", "sub-123"])
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert ["COUNT", "sub-count", payload] = Jason.decode!(response)
|
||||
assert payload["count"] >= 0
|
||||
assert payload["approximate"] == false
|
||||
end
|
||||
|
||||
test "AUTH accepts valid challenge event" do
|
||||
state = connection_state()
|
||||
|
||||
auth_event = valid_auth_event(state.auth_challenge)
|
||||
payload = Jason.encode!(["AUTH", auth_event])
|
||||
|
||||
assert {:push, {:text, response}, next_state} =
|
||||
Connection.handle_in({close_payload, [opcode: :text]}, subscribed_state)
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
refute Map.has_key?(next_state.subscriptions, "sub-123")
|
||||
assert Jason.decode!(response) == ["CLOSED", "sub-123", "error: subscription closed"]
|
||||
assert Jason.decode!(response) == ["OK", auth_event["id"], true, "ok: auth accepted"]
|
||||
assert MapSet.member?(next_state.authenticated_pubkeys, auth_event["pubkey"])
|
||||
refute next_state.auth_challenge == state.auth_challenge
|
||||
end
|
||||
|
||||
test "REQ above max subscriptions returns CLOSED and keeps existing subscriptions" do
|
||||
{:ok, state} = Connection.init(max_subscriptions_per_connection: 1)
|
||||
test "AUTH rejects mismatched challenge and returns AUTH frame" do
|
||||
state = connection_state()
|
||||
|
||||
req_one = Jason.encode!(["REQ", "sub-1", %{"kinds" => [1]}])
|
||||
req_two = Jason.encode!(["REQ", "sub-2", %{"kinds" => [1]}])
|
||||
auth_event = valid_auth_event("wrong-challenge")
|
||||
payload = Jason.encode!(["AUTH", auth_event])
|
||||
|
||||
assert {:push, _, first_state} = Connection.handle_in({req_one, [opcode: :text]}, state)
|
||||
assert {:push, frames, ^state} = Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert {:push, {:text, response}, second_state} =
|
||||
Connection.handle_in({req_two, [opcode: :text]}, first_state)
|
||||
decoded = Enum.map(frames, fn {:text, frame} -> Jason.decode!(frame) end)
|
||||
|
||||
assert map_size(second_state.subscriptions) == 1
|
||||
assert Map.has_key?(second_state.subscriptions, "sub-1")
|
||||
assert Enum.any?(decoded, fn frame -> frame == ["AUTH", state.auth_challenge] end)
|
||||
|
||||
assert Jason.decode!(response) == [
|
||||
"CLOSED",
|
||||
"sub-2",
|
||||
"rate-limited: maximum subscriptions per connection exceeded"
|
||||
]
|
||||
assert Enum.any?(decoded, fn frame ->
|
||||
match?(["OK", _, false, _], frame)
|
||||
end)
|
||||
end
|
||||
|
||||
test "invalid input returns NOTICE" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
test "protected event is rejected unless authenticated" do
|
||||
state = connection_state()
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({"not-json", [opcode: :text]}, state)
|
||||
event =
|
||||
valid_event()
|
||||
|> Map.put("tags", [["-"]])
|
||||
|> then(&Map.put(&1, "id", EventValidator.compute_id(&1)))
|
||||
|
||||
assert Jason.decode!(response) == ["NOTICE", "invalid: malformed JSON"]
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
|
||||
assert {:push, frames, ^state} = Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
decoded = Enum.map(frames, fn {:text, frame} -> Jason.decode!(frame) end)
|
||||
|
||||
assert ["OK", _, false, "auth-required: protected events require authenticated pubkey"] =
|
||||
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
|
||||
|
||||
assert Enum.any?(decoded, fn frame -> frame == ["AUTH", state.auth_challenge] end)
|
||||
end
|
||||
|
||||
test "REQ with invalid filter returns CLOSED and does not subscribe" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
|
||||
req_payload = Jason.encode!(["REQ", "sub-123", %{"kinds" => ["1"]}])
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == [
|
||||
"CLOSED",
|
||||
"sub-123",
|
||||
"invalid: kinds must be a non-empty array of integers between 0 and 65535"
|
||||
]
|
||||
end
|
||||
|
||||
test "valid EVENT currently replies with unsupported OK" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
test "valid EVENT stores event and returns accepted OK" do
|
||||
state = connection_state()
|
||||
|
||||
event = valid_event()
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
@@ -108,16 +102,11 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == [
|
||||
"OK",
|
||||
event["id"],
|
||||
false,
|
||||
"error: EVENT ingest not implemented"
|
||||
]
|
||||
assert Jason.decode!(response) == ["OK", event["id"], true, "ok: event stored"]
|
||||
end
|
||||
|
||||
test "invalid EVENT replies with OK false invalid prefix" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
state = connection_state()
|
||||
|
||||
event = valid_event() |> Map.put("sig", "nope")
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
@@ -133,6 +122,37 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
]
|
||||
end
|
||||
|
||||
test "NEG sessions open and close" do
|
||||
state = connection_state()
|
||||
|
||||
open_payload = Jason.encode!(["NEG-OPEN", "neg-1", %{"cursor" => 0}])
|
||||
|
||||
assert {:push, {:text, open_response}, ^state} =
|
||||
Connection.handle_in({open_payload, [opcode: :text]}, state)
|
||||
|
||||
assert ["NEG-MSG", "neg-1", %{"status" => "open", "cursor" => 0}] =
|
||||
Jason.decode!(open_response)
|
||||
|
||||
close_payload = Jason.encode!(["NEG-CLOSE", "neg-1"])
|
||||
|
||||
assert {:push, {:text, close_response}, ^state} =
|
||||
Connection.handle_in({close_payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(close_response) == ["NEG-MSG", "neg-1", %{"status" => "closed"}]
|
||||
end
|
||||
|
||||
test "CLOSE removes subscription and replies with CLOSED" do
|
||||
state = subscribed_connection_state([])
|
||||
|
||||
close_payload = Jason.encode!(["CLOSE", "sub-1"])
|
||||
|
||||
assert {:push, {:text, response}, next_state} =
|
||||
Connection.handle_in({close_payload, [opcode: :text]}, state)
|
||||
|
||||
refute Map.has_key?(next_state.subscriptions, "sub-1")
|
||||
assert Jason.decode!(response) == ["CLOSED", "sub-1", "error: subscription closed"]
|
||||
end
|
||||
|
||||
test "fanout_event enqueues and drains matching events" do
|
||||
state = subscribed_connection_state([])
|
||||
event = live_event("event-1", 1)
|
||||
@@ -149,16 +169,6 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
assert Jason.decode!(payload) == ["EVENT", "sub-1", event]
|
||||
end
|
||||
|
||||
test "fanout_event ignores non-matching subscription filters" do
|
||||
state = subscribed_connection_state([])
|
||||
|
||||
assert {:ok, next_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-1", live_event("event-2", 2)}, state)
|
||||
|
||||
assert next_state.outbound_queue_size == 0
|
||||
refute_received :drain_outbound_queue
|
||||
end
|
||||
|
||||
test "outbound queue overflow closes connection when strategy is close" do
|
||||
state =
|
||||
subscribed_connection_state(
|
||||
@@ -167,61 +177,36 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
outbound_drain_batch_size: 1
|
||||
)
|
||||
|
||||
event_one = live_event("event-1", 1)
|
||||
event_two = live_event("event-2", 1)
|
||||
|
||||
assert {:ok, queued_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-1", event_one}, state)
|
||||
Connection.handle_info({:fanout_event, "sub-1", live_event("event-1", 1)}, state)
|
||||
|
||||
assert queued_state.outbound_queue_size == 1
|
||||
assert_receive :drain_outbound_queue
|
||||
|
||||
assert {:stop, :normal, {1008, message}, [{:text, notice_payload}], _overflow_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-1", event_two}, queued_state)
|
||||
Connection.handle_info(
|
||||
{:fanout_event, "sub-1", live_event("event-2", 1)},
|
||||
queued_state
|
||||
)
|
||||
|
||||
assert message == "rate-limited: outbound queue overflow"
|
||||
assert Jason.decode!(notice_payload) == ["NOTICE", message]
|
||||
end
|
||||
|
||||
test "outbound queue overflow drops oldest event when strategy is drop_oldest" do
|
||||
state =
|
||||
subscribed_connection_state(
|
||||
max_outbound_queue: 1,
|
||||
outbound_overflow_strategy: :drop_oldest,
|
||||
outbound_drain_batch_size: 1
|
||||
)
|
||||
|
||||
event_one = live_event("event-1", 1)
|
||||
event_two = live_event("event-2", 1)
|
||||
|
||||
assert {:ok, queued_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-1", event_one}, state)
|
||||
|
||||
assert queued_state.outbound_queue_size == 1
|
||||
assert_receive :drain_outbound_queue
|
||||
|
||||
assert {:ok, replaced_state} =
|
||||
Connection.handle_info({:fanout_event, "sub-1", event_two}, queued_state)
|
||||
|
||||
assert replaced_state.outbound_queue_size == 1
|
||||
|
||||
assert {:push, [{:text, payload}], drained_state} =
|
||||
Connection.handle_info(:drain_outbound_queue, replaced_state)
|
||||
|
||||
assert drained_state.outbound_queue_size == 0
|
||||
assert Jason.decode!(payload) == ["EVENT", "sub-1", event_two]
|
||||
end
|
||||
|
||||
defp subscribed_connection_state(opts) do
|
||||
{:ok, initial_state} = Connection.init(Keyword.put_new(opts, :subscription_index, nil))
|
||||
state = connection_state(opts)
|
||||
req_payload = Jason.encode!(["REQ", "sub-1", %{"kinds" => [1]}])
|
||||
|
||||
assert {:push, _, subscribed_state} =
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, initial_state)
|
||||
Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||
|
||||
subscribed_state
|
||||
end
|
||||
|
||||
defp connection_state(opts \\ []) do
|
||||
{:ok, state} = Connection.init(Keyword.put_new(opts, :subscription_index, nil))
|
||||
state
|
||||
end
|
||||
|
||||
defp live_event(id, kind) do
|
||||
%{
|
||||
"id" => id,
|
||||
@@ -234,6 +219,21 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
}
|
||||
end
|
||||
|
||||
defp valid_auth_event(challenge) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("9", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 22_242,
|
||||
"tags" => [["challenge", challenge]],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("8", 128)
|
||||
}
|
||||
|
||||
Map.put(base, "id", EventValidator.compute_id(base))
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
base_event = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
|
||||
97
test/parrhesia/web/router_test.exs
Normal file
97
test/parrhesia/web/router_test.exs
Normal file
@@ -0,0 +1,97 @@
|
||||
defmodule Parrhesia.Web.RouterTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Web.Router
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "GET /health returns ok" do
|
||||
conn = conn(:get, "/health") |> Router.call([])
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body == "ok"
|
||||
end
|
||||
|
||||
test "GET /ready returns ready" do
|
||||
conn = conn(:get, "/ready") |> Router.call([])
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body == "ready"
|
||||
end
|
||||
|
||||
test "GET /relay with nostr accept header returns NIP-11 document" do
|
||||
conn =
|
||||
conn(:get, "/relay")
|
||||
|> put_req_header("accept", "application/nostr+json")
|
||||
|> Router.call([])
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-type") == ["application/nostr+json; charset=utf-8"]
|
||||
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
|
||||
assert body["name"] == "Parrhesia"
|
||||
assert 11 in body["supported_nips"]
|
||||
end
|
||||
|
||||
test "GET /metrics returns prometheus payload" do
|
||||
conn = conn(:get, "/metrics") |> Router.call([])
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
|
||||
end
|
||||
|
||||
test "POST /management requires authorization" do
|
||||
conn =
|
||||
conn(:post, "/management", Jason.encode!(%{"method" => "ping", "params" => %{}}))
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> Router.call([])
|
||||
|
||||
assert conn.status == 401
|
||||
assert Jason.decode!(conn.resp_body) == %{"ok" => false, "error" => "auth-required"}
|
||||
end
|
||||
|
||||
test "POST /management accepts valid NIP-98 header" do
|
||||
management_url = "http://www.example.com/management"
|
||||
auth_event = nip98_event("POST", management_url)
|
||||
|
||||
authorization = "Nostr " <> Base.encode64(Jason.encode!(auth_event))
|
||||
|
||||
conn =
|
||||
conn(:post, "/management", Jason.encode!(%{"method" => "ping", "params" => %{}}))
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("authorization", authorization)
|
||||
|> Router.call([])
|
||||
|
||||
assert conn.status == 200
|
||||
|
||||
assert Jason.decode!(conn.resp_body) == %{
|
||||
"ok" => true,
|
||||
"result" => %{"status" => "ok"}
|
||||
}
|
||||
end
|
||||
|
||||
defp nip98_event(method, url) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
base = %{
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"created_at" => now,
|
||||
"kind" => 27_235,
|
||||
"tags" => [["method", method], ["u", url]],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("b", 128)
|
||||
}
|
||||
|
||||
Map.put(base, "id", EventValidator.compute_id(base))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user