470 lines
13 KiB
Elixir
470 lines
13 KiB
Elixir
defmodule Parrhesia.Web.RouterTest do
|
|
use Parrhesia.IntegrationCase, async: false, sandbox: true
|
|
|
|
import Plug.Conn
|
|
import Plug.Test
|
|
|
|
alias Parrhesia.API.Sync
|
|
alias Parrhesia.Protocol.EventValidator
|
|
alias Parrhesia.Web.Listener
|
|
alias Parrhesia.Web.Router
|
|
|
|
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 = JSON.decode!(conn.resp_body)
|
|
|
|
assert body["name"] == "Parrhesia"
|
|
assert 11 in body["supported_nips"]
|
|
end
|
|
|
|
test "GET /metrics returns prometheus payload for private-network clients" do
|
|
conn =
|
|
conn(:get, "/metrics")
|
|
|> route_conn(
|
|
listener(%{
|
|
features: %{metrics: %{enabled: true, access: %{private_networks_only: true}}}
|
|
})
|
|
)
|
|
|
|
assert conn.status == 200
|
|
assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
|
|
end
|
|
|
|
test "GET /metrics denies public-network clients by default" do
|
|
conn = conn(:get, "/metrics")
|
|
conn = %{conn | remote_ip: {8, 8, 8, 8}}
|
|
|
|
test_listener =
|
|
listener(%{features: %{metrics: %{enabled: true, access: %{private_networks_only: true}}}})
|
|
|
|
conn = Listener.put_conn(conn, listener: test_listener)
|
|
|
|
refute Listener.metrics_allowed?(Listener.from_conn(conn), conn)
|
|
|
|
conn = Router.call(conn, [])
|
|
|
|
assert conn.status == 403
|
|
assert conn.resp_body == "forbidden"
|
|
end
|
|
|
|
test "GET /metrics can be disabled on the main endpoint" do
|
|
conn =
|
|
conn(:get, "/metrics")
|
|
|> route_conn(listener(%{features: %{metrics: %{enabled: false}}}))
|
|
|
|
assert conn.status == 404
|
|
assert conn.resp_body == "not found"
|
|
end
|
|
|
|
test "GET /metrics accepts bearer auth when configured" do
|
|
test_listener =
|
|
listener(%{
|
|
features: %{
|
|
metrics: %{
|
|
enabled: true,
|
|
access: %{private_networks_only: false},
|
|
auth_token: "secret-token"
|
|
}
|
|
}
|
|
})
|
|
|
|
denied_conn = conn(:get, "/metrics") |> route_conn(test_listener)
|
|
|
|
assert denied_conn.status == 403
|
|
|
|
allowed_conn =
|
|
conn(:get, "/metrics")
|
|
|> put_req_header("authorization", "Bearer secret-token")
|
|
|> route_conn(test_listener)
|
|
|
|
assert allowed_conn.status == 200
|
|
end
|
|
|
|
test "GET /relay accepts proxy-asserted TLS identity from trusted proxies" do
|
|
test_listener =
|
|
listener(%{
|
|
transport: %{
|
|
scheme: :http,
|
|
tls: %{
|
|
mode: :proxy_terminated,
|
|
proxy_headers: %{enabled: true, required: true}
|
|
}
|
|
},
|
|
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
|
})
|
|
|
|
conn =
|
|
conn(:get, "/relay")
|
|
|> put_req_header("accept", "application/nostr+json")
|
|
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|
|
|> put_req_header("x-parrhesia-client-spki-sha256", "proxy-spki-pin")
|
|
|> Plug.Test.put_peer_data(%{
|
|
address: {10, 1, 2, 3},
|
|
port: 443,
|
|
ssl_cert: nil
|
|
})
|
|
|> route_conn(test_listener)
|
|
|
|
assert conn.status == 200
|
|
end
|
|
|
|
test "GET /relay rejects missing proxy-asserted TLS identity when required" do
|
|
test_listener =
|
|
listener(%{
|
|
transport: %{
|
|
scheme: :http,
|
|
tls: %{
|
|
mode: :proxy_terminated,
|
|
proxy_headers: %{enabled: true, required: true}
|
|
}
|
|
},
|
|
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
|
})
|
|
|
|
conn =
|
|
conn(:get, "/relay")
|
|
|> put_req_header("accept", "application/nostr+json")
|
|
|> Plug.Test.put_peer_data(%{
|
|
address: {10, 1, 2, 3},
|
|
port: 443,
|
|
ssl_cert: nil
|
|
})
|
|
|> route_conn(test_listener)
|
|
|
|
assert conn.status == 403
|
|
assert conn.resp_body == "forbidden"
|
|
end
|
|
|
|
test "GET /relay rejects proxy-asserted TLS identity when the pin mismatches" do
|
|
test_listener =
|
|
listener(%{
|
|
transport: %{
|
|
scheme: :http,
|
|
tls: %{
|
|
mode: :proxy_terminated,
|
|
client_pins: [%{type: :spki_sha256, value: "expected-spki-pin"}],
|
|
proxy_headers: %{enabled: true, required: true}
|
|
}
|
|
},
|
|
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
|
})
|
|
|
|
conn =
|
|
conn(:get, "/relay")
|
|
|> put_req_header("accept", "application/nostr+json")
|
|
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|
|
|> put_req_header("x-parrhesia-client-spki-sha256", "wrong-spki-pin")
|
|
|> Plug.Test.put_peer_data(%{
|
|
address: {10, 1, 2, 3},
|
|
port: 443,
|
|
ssl_cert: nil
|
|
})
|
|
|> route_conn(test_listener)
|
|
|
|
assert conn.status == 403
|
|
assert conn.resp_body == "forbidden"
|
|
end
|
|
|
|
test "POST /management requires authorization" do
|
|
conn =
|
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
|
|> put_req_header("content-type", "application/json")
|
|
|> Router.call([])
|
|
|
|
assert conn.status == 401
|
|
assert JSON.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(JSON.encode!(auth_event))
|
|
|
|
conn =
|
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert conn.status == 200
|
|
|
|
assert JSON.decode!(conn.resp_body) == %{
|
|
"ok" => true,
|
|
"result" => %{"status" => "ok"}
|
|
}
|
|
end
|
|
|
|
test "POST /management denies blocked IPs before auth" do
|
|
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "8.8.8.8")
|
|
|
|
conn =
|
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
|
|> put_req_header("content-type", "application/json")
|
|
|> Map.put(:remote_ip, {8, 8, 8, 8})
|
|
|> Router.call([])
|
|
|
|
assert conn.status == 403
|
|
assert conn.resp_body == "forbidden"
|
|
end
|
|
|
|
test "GET /relay denies blocked IPs" do
|
|
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "8.8.4.4")
|
|
|
|
conn =
|
|
conn(:get, "/relay")
|
|
|> put_req_header("accept", "application/nostr+json")
|
|
|> Map.put(:remote_ip, {8, 8, 4, 4})
|
|
|> Router.call([])
|
|
|
|
assert conn.status == 403
|
|
assert conn.resp_body == "forbidden"
|
|
end
|
|
|
|
test "POST /management supports ACL methods" do
|
|
management_url = "http://www.example.com/management"
|
|
auth_event = nip98_event("POST", management_url)
|
|
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
|
|
grant_conn =
|
|
conn(
|
|
:post,
|
|
"/management",
|
|
JSON.encode!(%{
|
|
"method" => "acl_grant",
|
|
"params" => %{
|
|
"principal_type" => "pubkey",
|
|
"principal" => String.duplicate("c", 64),
|
|
"capability" => "sync_read",
|
|
"match" => %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
|
|
}
|
|
})
|
|
)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert grant_conn.status == 200
|
|
|
|
list_conn =
|
|
conn(
|
|
:post,
|
|
"/management",
|
|
JSON.encode!(%{
|
|
"method" => "acl_list",
|
|
"params" => %{"principal" => String.duplicate("c", 64)}
|
|
})
|
|
)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert list_conn.status == 200
|
|
|
|
assert %{
|
|
"ok" => true,
|
|
"result" => %{
|
|
"rules" => [
|
|
%{
|
|
"principal" => principal,
|
|
"capability" => "sync_read"
|
|
}
|
|
]
|
|
}
|
|
} = JSON.decode!(list_conn.resp_body)
|
|
|
|
assert principal == String.duplicate("c", 64)
|
|
end
|
|
|
|
test "POST /management supports identity methods" do
|
|
management_url = "http://www.example.com/management"
|
|
auth_event = nip98_event("POST", management_url)
|
|
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
|
|
conn =
|
|
conn(
|
|
:post,
|
|
"/management",
|
|
JSON.encode!(%{
|
|
"method" => "identity_ensure",
|
|
"params" => %{}
|
|
})
|
|
)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert conn.status == 200
|
|
|
|
assert %{
|
|
"ok" => true,
|
|
"result" => %{
|
|
"pubkey" => pubkey
|
|
}
|
|
} = JSON.decode!(conn.resp_body)
|
|
|
|
assert is_binary(pubkey)
|
|
assert byte_size(pubkey) == 64
|
|
end
|
|
|
|
test "POST /management stats and health include sync summary" do
|
|
management_url = "http://www.example.com/management"
|
|
auth_event = nip98_event("POST", management_url)
|
|
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
initial_total = Sync.sync_stats() |> elem(1) |> Map.fetch!("servers_total")
|
|
server_id = "router-sync-#{System.unique_integer([:positive, :monotonic])}"
|
|
|
|
assert {:ok, _server} =
|
|
Sync.put_server(%{
|
|
"id" => server_id,
|
|
"url" => "wss://relay-a.example/relay",
|
|
"enabled?" => false,
|
|
"auth_pubkey" => String.duplicate("a", 64),
|
|
"filters" => [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}],
|
|
"tls" => %{
|
|
"pins" => [
|
|
%{
|
|
"type" => "spki_sha256",
|
|
"value" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
on_exit(fn ->
|
|
_ = Sync.remove_server(server_id)
|
|
end)
|
|
|
|
stats_conn =
|
|
conn(
|
|
:post,
|
|
"/management",
|
|
JSON.encode!(%{
|
|
"method" => "stats",
|
|
"params" => %{}
|
|
})
|
|
)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert stats_conn.status == 200
|
|
|
|
assert %{
|
|
"ok" => true,
|
|
"result" => %{
|
|
"sync" => %{"servers_total" => servers_total}
|
|
}
|
|
} = JSON.decode!(stats_conn.resp_body)
|
|
|
|
assert servers_total == initial_total + 1
|
|
|
|
health_conn =
|
|
conn(
|
|
:post,
|
|
"/management",
|
|
JSON.encode!(%{
|
|
"method" => "health",
|
|
"params" => %{}
|
|
})
|
|
)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> put_req_header("authorization", authorization)
|
|
|> Router.call([])
|
|
|
|
assert health_conn.status == 200
|
|
|
|
assert %{
|
|
"ok" => true,
|
|
"result" => %{
|
|
"status" => status,
|
|
"sync" => %{"servers_total" => ^servers_total}
|
|
}
|
|
} = JSON.decode!(health_conn.resp_body)
|
|
|
|
assert status in ["ok", "degraded"]
|
|
end
|
|
|
|
test "POST /management returns not found when admin feature is disabled on the listener" do
|
|
conn =
|
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
|
|> put_req_header("content-type", "application/json")
|
|
|> route_conn(listener(%{features: %{admin: %{enabled: false}}}))
|
|
|
|
assert conn.status == 404
|
|
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
|
|
|
|
defp listener(overrides) do
|
|
deep_merge(
|
|
%{
|
|
id: :test,
|
|
enabled: true,
|
|
bind: %{ip: {127, 0, 0, 1}, port: 4413},
|
|
transport: %{scheme: :http, tls: %{mode: :disabled}},
|
|
proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true},
|
|
network: %{allow_all: true},
|
|
features: %{
|
|
nostr: %{enabled: true},
|
|
admin: %{enabled: true},
|
|
metrics: %{enabled: true, access: %{private_networks_only: true}, auth_token: nil}
|
|
},
|
|
auth: %{nip42_required: false, nip98_required_for_admin: true},
|
|
baseline_acl: %{read: [], write: []}
|
|
},
|
|
overrides
|
|
)
|
|
end
|
|
|
|
defp deep_merge(left, right) when is_map(left) and is_map(right) do
|
|
Map.merge(left, right, fn _key, left_value, right_value ->
|
|
if is_map(left_value) and is_map(right_value) do
|
|
deep_merge(left_value, right_value)
|
|
else
|
|
right_value
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp route_conn(conn, listener) do
|
|
conn
|
|
|> Listener.put_conn(listener: listener)
|
|
|> Router.call([])
|
|
end
|
|
end
|