Implement full NIP-43 relay access flow
This commit is contained in:
@@ -4,24 +4,45 @@ defmodule Parrhesia.Groups.FlowTest do
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Storage
|
||||
|
||||
test "handles membership request kinds by upserting group memberships" do
|
||||
test "handles join requests by upserting relay memberships" do
|
||||
event = %{
|
||||
"kind" => 8_000,
|
||||
"kind" => 28_934,
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"tags" => [["h", "group-1"]]
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"id" => "join-1"
|
||||
}
|
||||
|
||||
assert :ok = Flow.handle_event(event)
|
||||
|
||||
assert {:ok, membership} =
|
||||
Storage.groups().get_membership(%{}, "group-1", String.duplicate("a", 64))
|
||||
assert {:ok, membership} = Flow.get_membership(String.duplicate("a", 64))
|
||||
|
||||
assert membership.role == "requested"
|
||||
assert membership.role == "member"
|
||||
assert membership.metadata["source_kind"] == 28_934
|
||||
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)
|
||||
test "membership snapshot replaces the stored relay memberships" do
|
||||
assert {:ok, _membership} =
|
||||
Storage.groups().put_membership(%{}, %{
|
||||
group_id: "__relay_access__",
|
||||
pubkey: String.duplicate("a", 64),
|
||||
role: "member"
|
||||
})
|
||||
|
||||
snapshot = %{
|
||||
"kind" => 13_534,
|
||||
"tags" => [["-"], ["member", String.duplicate("b", 64)]],
|
||||
"id" => "snapshot-1"
|
||||
}
|
||||
|
||||
assert :ok = Flow.handle_event(snapshot)
|
||||
|
||||
assert {:ok, memberships} = Flow.list_memberships()
|
||||
assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)]
|
||||
end
|
||||
|
||||
test "marks configured relay access kinds as handled" do
|
||||
assert Flow.relay_access_kind?(28_934)
|
||||
assert Flow.relay_access_kind?(13_534)
|
||||
refute Flow.relay_access_kind?(1)
|
||||
end
|
||||
end
|
||||
|
||||
63
test/parrhesia/nip43_test.exs
Normal file
63
test/parrhesia/nip43_test.exs
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule Parrhesia.NIP43Test do
|
||||
use Parrhesia.IntegrationCase, async: false, sandbox: true
|
||||
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Web.Listener
|
||||
alias Parrhesia.Web.RelayInfo
|
||||
|
||||
test "relay-authored membership snapshots replace the stored access list" do
|
||||
first_snapshot =
|
||||
signed_relay_event(13_534, [
|
||||
["-"],
|
||||
["member", String.duplicate("a", 64)],
|
||||
["member", String.duplicate("b", 64)]
|
||||
])
|
||||
|
||||
second_snapshot =
|
||||
signed_relay_event(13_534, [
|
||||
["-"],
|
||||
["member", String.duplicate("b", 64)]
|
||||
])
|
||||
|
||||
assert {:ok, %{accepted: true}} = Events.publish(first_snapshot, context: %RequestContext{})
|
||||
assert {:ok, %{accepted: true}} = Events.publish(second_snapshot, context: %RequestContext{})
|
||||
|
||||
assert {:ok, memberships} = Flow.list_memberships()
|
||||
assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)]
|
||||
end
|
||||
|
||||
test "count includes generated invite responses for kind 28935 filters" do
|
||||
assert {:ok, 1} = Events.count([%{"kinds" => [28_935]}], context: %RequestContext{})
|
||||
end
|
||||
|
||||
test "relay info only advertises 43 when NIP-43 is enabled" do
|
||||
previous_config = Application.get_env(:parrhesia, :nip43, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :nip43, previous_config)
|
||||
end)
|
||||
|
||||
listener = Listener.from_opts(id: :public)
|
||||
|
||||
Application.put_env(:parrhesia, :nip43, enabled: false)
|
||||
refute 43 in RelayInfo.document(listener)["supported_nips"]
|
||||
|
||||
Application.put_env(:parrhesia, :nip43, enabled: true, invite_ttl_seconds: 900)
|
||||
assert 43 in RelayInfo.document(listener)["supported_nips"]
|
||||
end
|
||||
|
||||
defp signed_relay_event(kind, tags) do
|
||||
{:ok, event} =
|
||||
Identity.sign_event(%{
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => kind,
|
||||
"tags" => tags,
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
event
|
||||
end
|
||||
end
|
||||
123
test/parrhesia/protocol/event_validator_nip43_test.exs
Normal file
123
test/parrhesia/protocol/event_validator_nip43_test.exs
Normal file
@@ -0,0 +1,123 @@
|
||||
defmodule Parrhesia.Protocol.EventValidatorNIP43Test do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
test "accepts valid NIP-43 relay access events" do
|
||||
join_request =
|
||||
build_event(%{
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invite_response =
|
||||
build_event(%{
|
||||
"kind" => 28_935,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
leave_request =
|
||||
build_event(%{
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
add_user =
|
||||
build_event(%{
|
||||
"kind" => 8_000,
|
||||
"tags" => [["-"], ["p", String.duplicate("a", 64)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
remove_user =
|
||||
build_event(%{
|
||||
"kind" => 8_001,
|
||||
"tags" => [["-"], ["p", String.duplicate("b", 64)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
membership_list =
|
||||
build_event(%{
|
||||
"kind" => 13_534,
|
||||
"tags" => [
|
||||
["-"],
|
||||
["member", String.duplicate("c", 64)],
|
||||
["member", String.duplicate("d", 64)]
|
||||
],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert :ok = EventValidator.validate(join_request)
|
||||
assert :ok = EventValidator.validate(invite_response)
|
||||
assert :ok = EventValidator.validate(leave_request)
|
||||
assert :ok = EventValidator.validate(add_user)
|
||||
assert :ok = EventValidator.validate(remove_user)
|
||||
assert :ok = EventValidator.validate(membership_list)
|
||||
end
|
||||
|
||||
test "rejects malformed NIP-43 relay access events" do
|
||||
stale_join =
|
||||
build_event(%{
|
||||
"kind" => 28_934,
|
||||
"created_at" => System.system_time(:second) - 301,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
missing_claim =
|
||||
build_event(%{
|
||||
"kind" => 28_935,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_leave =
|
||||
build_event(%{
|
||||
"kind" => 28_936,
|
||||
"tags" => [],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_membership_delta =
|
||||
build_event(%{
|
||||
"kind" => 8_000,
|
||||
"tags" => [["-"], ["p", "not-a-pubkey"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_membership_list =
|
||||
build_event(%{
|
||||
"kind" => 13_534,
|
||||
"tags" => [["-"], ["member", "not-a-pubkey"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:error, :stale_nip43_join_request} = EventValidator.validate(stale_join)
|
||||
assert {:error, :missing_nip43_claim_tag} = EventValidator.validate(missing_claim)
|
||||
assert {:error, :missing_nip43_protected_tag} = EventValidator.validate(invalid_leave)
|
||||
|
||||
assert {:error, :invalid_nip43_pubkey_tag} =
|
||||
EventValidator.validate(invalid_membership_delta)
|
||||
|
||||
assert {:error, :invalid_nip43_member_tag} =
|
||||
EventValidator.validate(invalid_membership_list)
|
||||
end
|
||||
|
||||
defp build_event(overrides) do
|
||||
event =
|
||||
%{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("2", 128)
|
||||
}
|
||||
|> Map.merge(overrides)
|
||||
|
||||
Map.put(event, "id", EventValidator.compute_id(event))
|
||||
end
|
||||
end
|
||||
270
test/parrhesia/web/connection_nip43_test.exs
Normal file
270
test/parrhesia/web/connection_nip43_test.exs
Normal file
@@ -0,0 +1,270 @@
|
||||
defmodule Parrhesia.Web.ConnectionNIP43Test do
|
||||
use Parrhesia.IntegrationCase, async: false, sandbox: true
|
||||
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Protocol
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Storage
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
test "REQ for kind 28935 returns a relay-signed invite response" do
|
||||
state = connection_state()
|
||||
|
||||
assert {:push, frames, next_state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
assert next_state.subscriptions["sub-invite"].eose_sent?
|
||||
|
||||
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
|
||||
assert ["EOSE", "sub-invite"] = List.last(decoded)
|
||||
|
||||
assert ["EVENT", "sub-invite", invite_event] =
|
||||
Enum.find(decoded, fn frame -> List.first(frame) == "EVENT" end)
|
||||
|
||||
assert invite_event["kind"] == 28_935
|
||||
assert :ok = Protocol.validate_event(invite_event)
|
||||
assert is_binary(claim_from_event(invite_event))
|
||||
end
|
||||
|
||||
test "join request accepts valid claims, stores membership, and publishes membership events" do
|
||||
invite_event = request_invite_event()
|
||||
join_pubkey = String.duplicate("9", 64)
|
||||
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"pubkey" => join_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(response) == [
|
||||
"OK",
|
||||
join_request["id"],
|
||||
true,
|
||||
"info: welcome to ws://localhost:4413/relay!"
|
||||
]
|
||||
|
||||
assert {:ok, membership} = Flow.get_membership(join_pubkey)
|
||||
assert membership.role == "member"
|
||||
|
||||
assert {:ok, add_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [8_000], "#p" => [join_pubkey]}], [])
|
||||
|
||||
assert length(add_events) == 1
|
||||
|
||||
assert {:ok, membership_list_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [13_534]}], [])
|
||||
|
||||
assert Enum.any?(membership_list_events, fn event ->
|
||||
["member", join_pubkey] in event["tags"]
|
||||
end)
|
||||
end
|
||||
|
||||
test "join request rejects invalid claims" do
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", "invalid-claim"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(response) == [
|
||||
"OK",
|
||||
join_request["id"],
|
||||
false,
|
||||
"restricted: that is an invalid invite code."
|
||||
]
|
||||
end
|
||||
|
||||
test "duplicate join and leave requests return duplicate messages" do
|
||||
invite_event = request_invite_event()
|
||||
member_pubkey = String.duplicate("8", 64)
|
||||
|
||||
first_join =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
second_join =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 1,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", first_join]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:push, {:text, duplicate_join_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", second_join]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(duplicate_join_response) == [
|
||||
"OK",
|
||||
second_join["id"],
|
||||
true,
|
||||
"duplicate: you are already a member of this relay."
|
||||
]
|
||||
|
||||
leave_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
duplicate_leave =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 2,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, leave_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", leave_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(leave_response) == [
|
||||
"OK",
|
||||
leave_request["id"],
|
||||
true,
|
||||
"info: membership revoked."
|
||||
]
|
||||
|
||||
assert {:push, {:text, duplicate_leave_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", duplicate_leave]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(duplicate_leave_response) == [
|
||||
"OK",
|
||||
duplicate_leave["id"],
|
||||
true,
|
||||
"duplicate: you are not a member of this relay."
|
||||
]
|
||||
end
|
||||
|
||||
test "leave request publishes a remove event and prunes the membership list" do
|
||||
invite_event = request_invite_event()
|
||||
member_pubkey = String.duplicate("7", 64)
|
||||
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
leave_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 1,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", leave_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:ok, nil} = Flow.get_membership(member_pubkey)
|
||||
|
||||
assert {:ok, remove_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [8_001], "#p" => [member_pubkey]}], [])
|
||||
|
||||
assert length(remove_events) == 1
|
||||
|
||||
assert {:ok, membership_list_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [13_534]}], [])
|
||||
|
||||
assert Enum.any?(membership_list_events, fn event ->
|
||||
["member", member_pubkey] not in event["tags"]
|
||||
end)
|
||||
end
|
||||
|
||||
defp request_invite_event do
|
||||
state = connection_state()
|
||||
|
||||
assert {:push, frames, _next_state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
frames
|
||||
|> Enum.map(fn {:text, frame} -> JSON.decode!(frame) end)
|
||||
|> Enum.find_value(fn
|
||||
["EVENT", "sub-invite", invite_event] -> invite_event
|
||||
_frame -> nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp claim_from_event(event) do
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.find_value(fn
|
||||
["claim", claim | _rest] -> claim
|
||||
_tag -> nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp connection_state(opts \\ []) do
|
||||
{:ok, state} = Connection.init(Keyword.put_new(opts, :subscription_index, nil))
|
||||
state
|
||||
end
|
||||
|
||||
defp valid_event(overrides) do
|
||||
%{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
|> Map.merge(overrides)
|
||||
|> then(fn event -> Map.put(event, "id", EventValidator.compute_id(event)) end)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user