mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-04 17:01:25 +02:00
2830 lines
110 KiB
Ruby
2830 lines
110 KiB
Ruby
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require "spec_helper"
|
|
require "net/http"
|
|
require "openssl"
|
|
require "sqlite3"
|
|
require "set"
|
|
require "uri"
|
|
require "socket"
|
|
|
|
RSpec.describe PotatoMesh::App::Federation do
|
|
NODES_API_PATH = "/api/nodes".freeze
|
|
STATS_API_PATH = "/api/stats".freeze
|
|
FULL_DATA_UNAVAILABLE_REASON = "full data unavailable".freeze
|
|
HTTP_CONNECTION_DOUBLE = "Net::HTTPConnection".freeze
|
|
|
|
subject(:federation_helpers) do
|
|
Class.new do
|
|
extend PotatoMesh::App::Federation
|
|
|
|
class << self
|
|
def debug_messages
|
|
@debug_messages ||= []
|
|
end
|
|
|
|
def debug_log(message, **_metadata)
|
|
debug_messages << message
|
|
end
|
|
|
|
def reset_debug_messages
|
|
@debug_messages = []
|
|
end
|
|
|
|
def warn_messages
|
|
@warn_messages ||= []
|
|
end
|
|
|
|
def warn_log(message, **_metadata)
|
|
warn_messages << message
|
|
end
|
|
|
|
def reset_warn_messages
|
|
@warn_messages = []
|
|
end
|
|
|
|
def info_messages
|
|
@info_messages ||= []
|
|
end
|
|
|
|
def info_log(message, **metadata)
|
|
info_messages << [message, metadata]
|
|
end
|
|
|
|
def reset_info_messages
|
|
@info_messages = []
|
|
end
|
|
|
|
def settings
|
|
@settings ||= Struct.new(
|
|
:federation_thread,
|
|
:initial_federation_thread,
|
|
:federation_worker_pool,
|
|
:federation_shutdown_requested,
|
|
:federation_shutdown_hook_installed,
|
|
).new
|
|
end
|
|
|
|
def set(key, value)
|
|
writer = "#{key}="
|
|
if settings.respond_to?(writer)
|
|
settings.public_send(writer, value)
|
|
else
|
|
raise ArgumentError, "unsupported setting #{key}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
before do
|
|
federation_helpers.instance_variable_set(:@remote_instance_cert_store, nil)
|
|
federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil)
|
|
federation_helpers.reset_debug_messages
|
|
federation_helpers.reset_warn_messages
|
|
federation_helpers.reset_info_messages
|
|
federation_helpers.clear_federation_crawl_state!
|
|
federation_helpers.shutdown_federation_worker_pool!
|
|
end
|
|
|
|
after do
|
|
federation_helpers.clear_federation_crawl_state!
|
|
federation_helpers.shutdown_federation_worker_pool!
|
|
end
|
|
|
|
describe ".remote_instance_cert_store" do
|
|
it "initializes the store with default paths and disables CRL checks" do
|
|
store_double = Class.new do
|
|
attr_reader :default_paths_called, :assigned_flags
|
|
|
|
def set_default_paths
|
|
@default_paths_called = true
|
|
end
|
|
|
|
def flags=(value)
|
|
@assigned_flags = value
|
|
end
|
|
|
|
def respond_to_missing?(method_name, include_private = false)
|
|
method_name == :flags= || super
|
|
end
|
|
end.new
|
|
|
|
allow(OpenSSL::X509::Store).to receive(:new).and_return(store_double)
|
|
|
|
result = federation_helpers.remote_instance_cert_store
|
|
|
|
expect(result).to eq(store_double)
|
|
expect(store_double.default_paths_called).to be(true)
|
|
expect(store_double.assigned_flags).to eq(0)
|
|
end
|
|
|
|
it "memoizes the generated store" do
|
|
first = federation_helpers.remote_instance_cert_store
|
|
second = federation_helpers.remote_instance_cert_store
|
|
expect(second).to equal(first)
|
|
end
|
|
|
|
it "logs and returns nil when initialization fails" do
|
|
allow(OpenSSL::X509::Store).to receive(:new).and_raise(OpenSSL::X509::StoreError, "boom")
|
|
|
|
expect(federation_helpers.remote_instance_cert_store).to be_nil
|
|
expect(federation_helpers.debug_messages.last).to include("Failed to initialize certificate store")
|
|
end
|
|
end
|
|
|
|
describe ".remote_instance_verify_callback" do
|
|
let(:callback) { federation_helpers.remote_instance_verify_callback }
|
|
|
|
it "memoizes the generated callback" do
|
|
first = federation_helpers.remote_instance_verify_callback
|
|
second = federation_helpers.remote_instance_verify_callback
|
|
expect(second).to equal(first)
|
|
end
|
|
|
|
it "allows the handshake to continue when CRLs are unavailable" do
|
|
store_context = instance_double(OpenSSL::X509::StoreContext, error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL)
|
|
|
|
expect(callback.call(false, store_context)).to be(true)
|
|
expect(federation_helpers.debug_messages.last).to include("Ignoring TLS CRL retrieval failure")
|
|
end
|
|
|
|
it "rejects other verification failures" do
|
|
store_context = instance_double(OpenSSL::X509::StoreContext, error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED)
|
|
|
|
expect(callback.call(false, store_context)).to be(false)
|
|
end
|
|
|
|
it "falls back to the default behavior when the handshake is already valid" do
|
|
expect(callback.call(true, nil)).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".build_remote_http_client" do
|
|
let(:connect_timeout) { 5 }
|
|
let(:read_timeout) { 12 }
|
|
let(:public_addrinfo) { Addrinfo.ip("203.0.113.5") }
|
|
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(connect_timeout)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(read_timeout)
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([public_addrinfo])
|
|
end
|
|
|
|
it "configures SSL settings for HTTPS endpoints" do
|
|
uri = URI.parse("https://remote.example.com/api")
|
|
store = OpenSSL::X509::Store.new
|
|
allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(store)
|
|
callback = proc { true }
|
|
allow(federation_helpers).to receive(:remote_instance_verify_callback).and_return(callback)
|
|
|
|
http = federation_helpers.build_remote_http_client(uri)
|
|
|
|
expect(http.use_ssl?).to be(true)
|
|
expect(http.open_timeout).to eq(connect_timeout)
|
|
expect(http.read_timeout).to eq(read_timeout)
|
|
expect(http.cert_store).to eq(store)
|
|
expect(http.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
|
|
expect(http.verify_callback).to eq(callback)
|
|
if http.respond_to?(:min_version)
|
|
expect(http.min_version).to eq(:TLS1_2)
|
|
end
|
|
end
|
|
|
|
it "omits SSL configuration for HTTP endpoints" do
|
|
uri = URI.parse("http://remote.example.com/api")
|
|
|
|
http = federation_helpers.build_remote_http_client(uri)
|
|
|
|
expect(http.use_ssl?).to be(false)
|
|
expect(http.cert_store).to be_nil
|
|
expect(http.open_timeout).to eq(connect_timeout)
|
|
expect(http.read_timeout).to eq(read_timeout)
|
|
end
|
|
|
|
it "leaves the certificate store unset when unavailable" do
|
|
uri = URI.parse("https://remote.example.com/api")
|
|
allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(nil)
|
|
allow(federation_helpers).to receive(:remote_instance_verify_callback).and_return(nil)
|
|
|
|
http = federation_helpers.build_remote_http_client(uri)
|
|
|
|
expect(http.cert_store).to be_nil
|
|
expect(http.verify_callback).to be_nil
|
|
end
|
|
|
|
it "rejects URIs that resolve exclusively to restricted addresses" do
|
|
uri = URI.parse("https://loopback.mesh/api")
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
|
|
|
|
expect do
|
|
federation_helpers.build_remote_http_client(uri)
|
|
end.to raise_error(ArgumentError, "restricted domain")
|
|
end
|
|
|
|
it "binds the HTTP client to the first unrestricted address" do
|
|
uri = URI.parse("https://remote.example.com/api")
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([
|
|
Addrinfo.ip("127.0.0.1"),
|
|
public_addrinfo,
|
|
Addrinfo.ip("10.0.0.3"),
|
|
])
|
|
|
|
http = federation_helpers.build_remote_http_client(uri)
|
|
|
|
if http.respond_to?(:ipaddr)
|
|
expect(http.ipaddr).to eq("203.0.113.5")
|
|
else
|
|
skip "Net::HTTP#ipaddr accessor unavailable"
|
|
end
|
|
end
|
|
|
|
it "pins to the explicit ip_address when provided" do
|
|
uri = URI.parse("https://remote.example.com/api")
|
|
|
|
http = federation_helpers.build_remote_http_client(uri, ip_address: "198.51.100.7")
|
|
|
|
if http.respond_to?(:ipaddr)
|
|
expect(http.ipaddr).to eq("198.51.100.7")
|
|
else
|
|
skip "Net::HTTP#ipaddr accessor unavailable"
|
|
end
|
|
expect(Addrinfo).not_to have_received(:getaddrinfo)
|
|
end
|
|
|
|
it "skips DNS resolution when ip_address is provided" do
|
|
uri = URI.parse("http://remote.example.com/api")
|
|
|
|
federation_helpers.build_remote_http_client(uri, ip_address: "198.51.100.7")
|
|
|
|
expect(Addrinfo).not_to have_received(:getaddrinfo)
|
|
end
|
|
end
|
|
|
|
describe ".sort_addresses_for_connection" do
|
|
it "returns an empty array unchanged" do
|
|
expect(federation_helpers.send(:sort_addresses_for_connection, [])).to eq([])
|
|
end
|
|
|
|
it "returns nil unchanged" do
|
|
expect(federation_helpers.send(:sort_addresses_for_connection, nil)).to be_nil
|
|
end
|
|
|
|
it "returns a single-element array unchanged" do
|
|
addr = [IPAddr.new("203.0.113.5")]
|
|
expect(federation_helpers.send(:sort_addresses_for_connection, addr)).to eq(addr)
|
|
end
|
|
|
|
it "places IPv4 addresses before IPv6" do
|
|
v6 = IPAddr.new("2001:db8::1")
|
|
v4 = IPAddr.new("203.0.113.5")
|
|
|
|
result = federation_helpers.send(:sort_addresses_for_connection, [v6, v4])
|
|
|
|
expect(result).to eq([v4, v6])
|
|
end
|
|
|
|
it "preserves relative order within the same address family" do
|
|
v4a = IPAddr.new("203.0.113.1")
|
|
v4b = IPAddr.new("203.0.113.2")
|
|
v6a = IPAddr.new("2001:db8::1")
|
|
v6b = IPAddr.new("2001:db8::2")
|
|
|
|
result = federation_helpers.send(:sort_addresses_for_connection, [v6a, v4a, v6b, v4b])
|
|
|
|
expect(result).to eq([v4a, v4b, v6a, v6b])
|
|
end
|
|
|
|
it "preserves order when IPv4 already comes first" do
|
|
v4 = IPAddr.new("203.0.113.5")
|
|
v6 = IPAddr.new("2001:db8::1")
|
|
|
|
result = federation_helpers.send(:sort_addresses_for_connection, [v4, v6])
|
|
|
|
expect(result).to eq([v4, v6])
|
|
end
|
|
end
|
|
|
|
describe ".connection_refused_or_unreachable?" do
|
|
it "returns true for Errno::ECONNREFUSED" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Errno::ECONNREFUSED.new)).to be(true)
|
|
end
|
|
|
|
it "returns true for Errno::EHOSTUNREACH" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Errno::EHOSTUNREACH.new)).to be(true)
|
|
end
|
|
|
|
it "returns true for Errno::ENETUNREACH" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Errno::ENETUNREACH.new)).to be(true)
|
|
end
|
|
|
|
it "returns true for Errno::ECONNRESET" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Errno::ECONNRESET.new)).to be(true)
|
|
end
|
|
|
|
it "returns true for Errno::ETIMEDOUT" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Errno::ETIMEDOUT.new)).to be(true)
|
|
end
|
|
|
|
it "returns true for Net::OpenTimeout" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Net::OpenTimeout.new)).to be(true)
|
|
end
|
|
|
|
it "returns false for OpenSSL::SSL::SSLError" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, OpenSSL::SSL::SSLError.new("fail"))).to be(false)
|
|
end
|
|
|
|
it "returns false for Net::ReadTimeout" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, Net::ReadTimeout.new)).to be(false)
|
|
end
|
|
|
|
it "returns false for a generic StandardError" do
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, StandardError.new("nope"))).to be(false)
|
|
end
|
|
|
|
it "walks the cause chain to find a retryable error" do
|
|
outer_with_cause = begin
|
|
begin
|
|
raise Errno::ECONNREFUSED, "refused"
|
|
rescue Errno::ECONNREFUSED
|
|
raise StandardError, "wrapper"
|
|
end
|
|
rescue StandardError => e
|
|
e
|
|
end
|
|
|
|
expect(outer_with_cause).to be_a(StandardError)
|
|
expect(outer_with_cause.cause).to be_a(Errno::ECONNREFUSED)
|
|
expect(federation_helpers.send(:connection_refused_or_unreachable?, outer_with_cause)).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".ingest_known_instances_from!" do
|
|
let(:db) { double(:db) }
|
|
let(:seed_domain) { "seed.mesh" }
|
|
let(:payload_entries) do
|
|
Array.new(3) do |index|
|
|
{
|
|
"id" => "remote-#{index}",
|
|
"domain" => "ally-#{index}.mesh",
|
|
"pubkey" => "ignored-pubkey-#{index}",
|
|
"signature" => "ignored-signature-#{index}",
|
|
}
|
|
end
|
|
end
|
|
let(:attributes_list) do
|
|
payload_entries.map do |entry|
|
|
{
|
|
id: entry["id"],
|
|
domain: entry["domain"],
|
|
pubkey: entry["pubkey"],
|
|
name: nil,
|
|
version: nil,
|
|
channel: nil,
|
|
frequency: nil,
|
|
latitude: nil,
|
|
longitude: nil,
|
|
last_update_time: nil,
|
|
is_private: false,
|
|
}
|
|
end
|
|
end
|
|
let(:node_payload) do
|
|
Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
|
|
{ "node_id" => "node-#{index}", "last_heard" => Time.now.to_i - index }
|
|
end
|
|
end
|
|
let(:response_map) do
|
|
mapping = { [seed_domain, "/api/instances"] => [payload_entries, :instances] }
|
|
attributes_list.each do |attributes|
|
|
mapping[[attributes[:domain], NODES_API_PATH]] = [node_payload, :nodes]
|
|
mapping[[attributes[:domain], "/api/instances"]] = [[], :instances]
|
|
end
|
|
mapping
|
|
end
|
|
|
|
before do
|
|
allow(federation_helpers).to receive(:fetch_instance_json) do |host, path|
|
|
response_map.fetch([host, path]) { [nil, []] }
|
|
end
|
|
allow(federation_helpers).to receive(:verify_instance_signature).and_return(true)
|
|
allow(federation_helpers).to receive(:validate_remote_nodes).and_return([true, nil])
|
|
payload_entries.each_with_index do |entry, index|
|
|
allow(federation_helpers).to receive(:remote_instance_attributes_from_payload).with(entry).and_return([attributes_list[index], "signature-#{index}", nil])
|
|
end
|
|
end
|
|
|
|
def configure_remote_node_window(now)
|
|
allow(Time).to receive(:now).and_return(now)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_max_node_age).and_return(900)
|
|
end
|
|
|
|
def stats_mapping(now:, stats_response:, full_nodes_response:, window_nodes_response: nil)
|
|
recent_cutoff = now.to_i - 900
|
|
mapping = { [seed_domain, "/api/instances"] => [payload_entries, :instances] }
|
|
attributes_list.each do |attributes|
|
|
mapping[[attributes[:domain], STATS_API_PATH]] = stats_response
|
|
mapping[[attributes[:domain], NODES_API_PATH]] = full_nodes_response
|
|
mapping[[attributes[:domain], "/api/instances"]] = [[], :instances]
|
|
next unless window_nodes_response
|
|
|
|
mapping[[attributes[:domain], "/api/nodes?since=#{recent_cutoff}&limit=1000"]] = window_nodes_response
|
|
end
|
|
mapping
|
|
end
|
|
|
|
def stub_ingest_fetches(mapping, capture_paths: false)
|
|
captured_paths = []
|
|
allow(federation_helpers).to receive(:fetch_instance_json) do |host, path|
|
|
captured_paths << [host, path] if capture_paths
|
|
mapping.fetch([host, path]) { [nil, []] }
|
|
end
|
|
allow(federation_helpers).to receive(:verify_instance_signature).and_return(true)
|
|
allow(federation_helpers).to receive(:validate_remote_nodes).and_return([true, nil])
|
|
allow(federation_helpers).to receive(:upsert_instance_record)
|
|
captured_paths
|
|
end
|
|
|
|
it "stops processing once the per-response limit is exceeded" do
|
|
processed_domains = []
|
|
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
|
|
processed_domains << attrs[:domain]
|
|
end
|
|
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(2)
|
|
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(10)
|
|
|
|
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(processed_domains).to eq([
|
|
attributes_list[0][:domain],
|
|
attributes_list[1][:domain],
|
|
])
|
|
expect(visited).to include(seed_domain, attributes_list[0][:domain], attributes_list[1][:domain])
|
|
expect(visited).not_to include(attributes_list[2][:domain])
|
|
expect(federation_helpers.debug_messages).to include(a_string_including("response limit"))
|
|
end
|
|
|
|
it "halts recursion once the crawl limit would be exceeded" do
|
|
processed_domains = []
|
|
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
|
|
processed_domains << attrs[:domain]
|
|
end
|
|
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(5)
|
|
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(2)
|
|
|
|
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(processed_domains).to eq([attributes_list.first[:domain]])
|
|
expect(visited).to include(seed_domain, attributes_list.first[:domain])
|
|
expect(visited).not_to include(attributes_list[1][:domain], attributes_list[2][:domain])
|
|
expect(federation_helpers.debug_messages).to include(a_string_including("crawl limit"))
|
|
end
|
|
|
|
it "prefers /api/stats when counting remote activity" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [{ "active_nodes" => { "hour" => 5, "day" => 7, "week" => 9, "month" => 11 }, "sampled" => false }, :stats],
|
|
full_nodes_response: [node_payload, :nodes],
|
|
)
|
|
captured_paths = stub_ingest_fetches(mapping, capture_paths: true)
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], STATS_API_PATH],
|
|
[attributes_list[1][:domain], STATS_API_PATH],
|
|
[attributes_list[2][:domain], STATS_API_PATH],
|
|
)
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], NODES_API_PATH],
|
|
[attributes_list[1][:domain], NODES_API_PATH],
|
|
[attributes_list[2][:domain], NODES_API_PATH],
|
|
)
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(5))
|
|
end
|
|
|
|
it "reads the 0.7.0 stats shape for total and per-protocol counts" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
|
|
new_shape = {
|
|
"total" => { "nodes" => { "hour" => 5, "day" => 7, "week" => 9, "month" => 11 } },
|
|
"meshcore" => { "nodes" => { "hour" => 1, "day" => 2, "week" => 3, "month" => 4 } },
|
|
"meshtastic" => { "nodes" => { "hour" => 4, "day" => 5, "week" => 6, "month" => 7 } },
|
|
"reticulum" => { "nodes" => { "hour" => 0, "day" => 0, "week" => 0, "month" => 0 } },
|
|
"sampled" => false,
|
|
}
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [new_shape, :stats],
|
|
full_nodes_response: [node_payload, :nodes],
|
|
)
|
|
stub_ingest_fetches(mapping)
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
# nodes_count comes from total.nodes (the 900s max age selects the hour window).
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(5))
|
|
# Per-protocol 24h counts come from <protocol>.nodes.day.
|
|
expect(attributes_list.map { |attrs| attrs[:meshcore_nodes_count] }).to all(eq(2))
|
|
expect(attributes_list.map { |attrs| attrs[:meshtastic_nodes_count] }).to all(eq(5))
|
|
end
|
|
|
|
it "prefers recent node window counts when /api/stats is unavailable" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
full_nodes_payload = node_payload.take(2)
|
|
recent_window_payload = node_payload
|
|
recent_path = "/api/nodes?since=#{now.to_i - 900}&limit=1000"
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [nil, ["stats unavailable"]],
|
|
full_nodes_response: [full_nodes_payload, :nodes],
|
|
window_nodes_response: [recent_window_payload, :nodes],
|
|
)
|
|
captured_paths = stub_ingest_fetches(mapping, capture_paths: true)
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], STATS_API_PATH],
|
|
[attributes_list[1][:domain], STATS_API_PATH],
|
|
[attributes_list[2][:domain], STATS_API_PATH],
|
|
)
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], NODES_API_PATH],
|
|
[attributes_list[1][:domain], NODES_API_PATH],
|
|
[attributes_list[2][:domain], NODES_API_PATH],
|
|
)
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], recent_path],
|
|
[attributes_list[1][:domain], recent_path],
|
|
[attributes_list[2][:domain], recent_path],
|
|
)
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(recent_window_payload.length))
|
|
end
|
|
|
|
it "falls back to recent node window when full node data is unavailable" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [nil, ["stats unavailable"]],
|
|
full_nodes_response: [nil, [FULL_DATA_UNAVAILABLE_REASON]],
|
|
window_nodes_response: [node_payload, :nodes],
|
|
)
|
|
stub_ingest_fetches(mapping)
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(node_payload.length))
|
|
end
|
|
|
|
it "uses recent node window fallback when stats succeed but full node data is unavailable" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
recent_path = "/api/nodes?since=#{now.to_i - 900}&limit=1000"
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [{ "active_nodes" => { "hour" => 9, "day" => 10, "week" => 11, "month" => 12 }, "sampled" => false }, :stats],
|
|
full_nodes_response: [nil, [FULL_DATA_UNAVAILABLE_REASON]],
|
|
window_nodes_response: [node_payload, :nodes],
|
|
)
|
|
captured_paths = stub_ingest_fetches(mapping, capture_paths: true)
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], STATS_API_PATH],
|
|
[attributes_list[1][:domain], STATS_API_PATH],
|
|
[attributes_list[2][:domain], STATS_API_PATH],
|
|
)
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], recent_path],
|
|
[attributes_list[1][:domain], recent_path],
|
|
[attributes_list[2][:domain], recent_path],
|
|
)
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(9))
|
|
end
|
|
|
|
it "handles URI metadata from malformed /api/stats payloads without crashing" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [{ "unexpected" => "shape" }, URI.parse("https://ally-0.mesh/api/stats")],
|
|
full_nodes_response: [node_payload.take(2), :nodes],
|
|
window_nodes_response: [node_payload, :nodes],
|
|
)
|
|
stub_ingest_fetches(mapping)
|
|
|
|
expect do
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
end.not_to raise_error
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(node_payload.length))
|
|
end
|
|
|
|
it "skips remote entries when both full and window node feeds are unavailable" do
|
|
now = Time.at(1_700_000_000)
|
|
configure_remote_node_window(now)
|
|
recent_path = "/api/nodes?since=#{now.to_i - 900}&limit=1000"
|
|
|
|
mapping = stats_mapping(
|
|
now:,
|
|
stats_response: [{ "active_nodes" => { "hour" => 3, "day" => 3, "week" => 3, "month" => 3 }, "sampled" => false }, :stats],
|
|
full_nodes_response: [nil, [FULL_DATA_UNAVAILABLE_REASON]],
|
|
window_nodes_response: [nil, ["window unavailable"]],
|
|
)
|
|
captured_paths = stub_ingest_fetches(mapping, capture_paths: true)
|
|
upserted = []
|
|
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
|
|
upserted << attrs
|
|
end
|
|
|
|
federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
|
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], NODES_API_PATH],
|
|
[attributes_list[1][:domain], NODES_API_PATH],
|
|
[attributes_list[2][:domain], NODES_API_PATH],
|
|
)
|
|
expect(captured_paths).to include(
|
|
[attributes_list[0][:domain], recent_path],
|
|
[attributes_list[1][:domain], recent_path],
|
|
[attributes_list[2][:domain], recent_path],
|
|
)
|
|
expect(upserted).to be_empty
|
|
expect(federation_helpers.warn_messages).to include("Failed to load remote node data")
|
|
expect(attributes_list.map { |attrs| attrs[:nodes_count] }).to all(eq(3))
|
|
end
|
|
end
|
|
|
|
describe ".upsert_instance_record" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
let(:base_attributes) do
|
|
{
|
|
id: "remote-instance",
|
|
domain: "Remote.Mesh",
|
|
pubkey: PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
|
|
name: "Remote Mesh",
|
|
version: "1.0.0",
|
|
channel: "longfox",
|
|
frequency: "915",
|
|
latitude: 45.0,
|
|
longitude: -122.0,
|
|
last_update_time: Time.now.to_i,
|
|
is_private: false,
|
|
contact_link: "https://example.org/contact",
|
|
}
|
|
end
|
|
|
|
def with_db
|
|
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
|
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
|
|
db.execute("PRAGMA foreign_keys = ON")
|
|
yield db
|
|
ensure
|
|
db&.close
|
|
end
|
|
|
|
before do
|
|
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
|
|
application_class.init_db unless application_class.db_schema_present?
|
|
application_class.ensure_schema_upgrades
|
|
with_db do |db|
|
|
db.execute("DELETE FROM instances")
|
|
end
|
|
allow(federation_helpers).to receive(:ip_from_domain).and_return(nil)
|
|
end
|
|
|
|
it "inserts the contact_link for new records" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
|
|
|
stored = db.get_first_value("SELECT contact_link FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(stored).to eq("https://example.org/contact")
|
|
end
|
|
end
|
|
|
|
it "updates the contact_link on conflict" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
|
|
|
federation_helpers.send(
|
|
:upsert_instance_record,
|
|
db,
|
|
base_attributes.merge(contact_link: "https://example.org/new-contact", name: "Renamed Mesh"),
|
|
"sig-2",
|
|
)
|
|
|
|
row =
|
|
db.get_first_row("SELECT contact_link, name, signature FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(row[0]).to eq("https://example.org/new-contact")
|
|
expect(row[1]).to eq("Renamed Mesh")
|
|
expect(row[2]).to eq("sig-2")
|
|
end
|
|
end
|
|
|
|
it "allows the contact_link to be cleared" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(contact_link: nil), "sig-3")
|
|
|
|
row = db.get_first_row("SELECT contact_link, signature FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(row[0]).to be_nil
|
|
expect(row[1]).to eq("sig-3")
|
|
end
|
|
end
|
|
|
|
it "stores the nodes_count for new records" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 77), "sig-1")
|
|
|
|
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(stored).to eq(77)
|
|
end
|
|
end
|
|
|
|
it "updates the nodes_count on conflict" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 12), "sig-1")
|
|
|
|
federation_helpers.send(
|
|
:upsert_instance_record,
|
|
db,
|
|
base_attributes.merge(nodes_count: 99, name: "Renamed Mesh"),
|
|
"sig-2",
|
|
)
|
|
|
|
row =
|
|
db.get_first_row("SELECT nodes_count, name, signature FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(row[0]).to eq(99)
|
|
expect(row[1]).to eq("Renamed Mesh")
|
|
expect(row[2]).to eq("sig-2")
|
|
end
|
|
end
|
|
|
|
it "stores per-protocol node counts for new records" do
|
|
with_db do |db|
|
|
attrs = base_attributes.merge(
|
|
nodes_count: 50,
|
|
meshcore_nodes_count: 20,
|
|
meshtastic_nodes_count: 30,
|
|
)
|
|
federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1")
|
|
|
|
row = db.get_first_row(
|
|
"SELECT nodes_count, meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
|
|
base_attributes[:id],
|
|
)
|
|
expect(row[0]).to eq(50)
|
|
expect(row[1]).to eq(20)
|
|
expect(row[2]).to eq(30)
|
|
end
|
|
end
|
|
|
|
it "updates per-protocol node counts on conflict" do
|
|
with_db do |db|
|
|
attrs = base_attributes.merge(
|
|
meshcore_nodes_count: 10,
|
|
meshtastic_nodes_count: 15,
|
|
)
|
|
federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1")
|
|
|
|
updated_attrs = base_attributes.merge(
|
|
meshcore_nodes_count: 25,
|
|
meshtastic_nodes_count: 40,
|
|
)
|
|
federation_helpers.send(:upsert_instance_record, db, updated_attrs, "sig-2")
|
|
|
|
row = db.get_first_row(
|
|
"SELECT meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
|
|
base_attributes[:id],
|
|
)
|
|
expect(row[0]).to eq(25)
|
|
expect(row[1]).to eq(40)
|
|
end
|
|
end
|
|
|
|
it "preserves nodes_count when re-upserted with nil" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 77), "sig-1")
|
|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-2")
|
|
|
|
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(stored).to eq(77)
|
|
end
|
|
end
|
|
|
|
it "preserves per-protocol counts when re-upserted with nil" do
|
|
with_db do |db|
|
|
attrs = base_attributes.merge(
|
|
meshcore_nodes_count: 20,
|
|
meshtastic_nodes_count: 30,
|
|
)
|
|
federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1")
|
|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-2")
|
|
|
|
row = db.get_first_row(
|
|
"SELECT meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
|
|
base_attributes[:id],
|
|
)
|
|
expect(row[0]).to eq(20)
|
|
expect(row[1]).to eq(30)
|
|
end
|
|
end
|
|
|
|
it "allows nodes_count to be updated to zero" do
|
|
with_db do |db|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 50), "sig-1")
|
|
|
|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 0), "sig-2")
|
|
|
|
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
|
|
expect(stored).to eq(0)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".federation_user_agent_header" do
|
|
it "combines the version and sanitized domain" do
|
|
allow(federation_helpers).to receive(:app_constant).and_call_original
|
|
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("9.9.9")
|
|
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
|
|
|
|
header = federation_helpers.federation_user_agent_header
|
|
|
|
expect(header).to eq("PotatoMesh/9.9.9 (+https://example.mesh)")
|
|
end
|
|
|
|
it "falls back to the product name when the domain is unavailable" do
|
|
allow(federation_helpers).to receive(:app_constant).and_call_original
|
|
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("1.2.3")
|
|
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return(nil)
|
|
|
|
header = federation_helpers.federation_user_agent_header
|
|
|
|
expect(header).to eq("PotatoMesh/1.2.3")
|
|
end
|
|
|
|
it "uses an explicit unknown marker when the version is blank" do
|
|
allow(federation_helpers).to receive(:app_constant).and_call_original
|
|
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("")
|
|
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
|
|
|
|
header = federation_helpers.federation_user_agent_header
|
|
|
|
expect(header).to eq("PotatoMesh/unknown (+https://example.mesh)")
|
|
end
|
|
end
|
|
|
|
describe ".perform_single_http_request" do
|
|
let(:uri) { URI.parse("https://remote.example.com/api") }
|
|
let(:http_client) { instance_double(Net::HTTP) }
|
|
|
|
before do
|
|
allow(federation_helpers).to receive(:build_remote_http_client).and_return(http_client)
|
|
end
|
|
|
|
it "wraps errors that omit a message with the error class name" do
|
|
stub_const(
|
|
"RemoteTcpFailure",
|
|
Class.new(StandardError) do
|
|
def message
|
|
""
|
|
end
|
|
end,
|
|
)
|
|
|
|
allow(http_client).to receive(:start).and_raise(RemoteTcpFailure.new)
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_single_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, "RemoteTcpFailure")
|
|
end
|
|
|
|
it "includes the error class name when the message omits it" do
|
|
allow(http_client).to receive(:start).and_raise(OpenSSL::SSL::SSLError.new("handshake failed"))
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_single_http_request, uri)
|
|
end.to raise_error(
|
|
PotatoMesh::App::InstanceFetchError,
|
|
"OpenSSL::SSL::SSLError: handshake failed",
|
|
)
|
|
end
|
|
|
|
it "preserves messages that already include the error class" do
|
|
allow(http_client).to receive(:start).and_raise(Net::ReadTimeout.new)
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_single_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
|
|
end
|
|
|
|
it "applies federation headers to instance fetch requests" do
|
|
connection = instance_double(HTTP_CONNECTION_DOUBLE)
|
|
success_response = Net::HTTPOK.new("1.1", "200", "OK")
|
|
allow(success_response).to receive(:body).and_return("{}")
|
|
allow(success_response).to receive(:code).and_return("200")
|
|
|
|
captured_request = nil
|
|
allow(http_client).to receive(:start) do |&block|
|
|
block.call(connection)
|
|
end
|
|
allow(connection).to receive(:request) do |request|
|
|
captured_request = request
|
|
success_response
|
|
end
|
|
|
|
result = federation_helpers.send(:perform_single_http_request, uri)
|
|
|
|
expect(result).to eq("{}")
|
|
expect(captured_request).not_to be_nil
|
|
expect(captured_request["Accept"]).to eq("application/json")
|
|
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
|
|
expect(captured_request["Content-Type"]).to be_nil
|
|
end
|
|
|
|
it "wraps non-success HTTP responses" do
|
|
connection = instance_double(HTTP_CONNECTION_DOUBLE)
|
|
failure_response = Net::HTTPBadGateway.new("1.1", "502", "Bad Gateway")
|
|
allow(failure_response).to receive(:code).and_return("502")
|
|
|
|
allow(http_client).to receive(:start) do |&block|
|
|
block.call(connection)
|
|
end
|
|
allow(connection).to receive(:request).and_return(failure_response)
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_single_http_request, uri)
|
|
end.to raise_error(
|
|
PotatoMesh::App::InstanceFetchError,
|
|
a_string_including("unexpected response 502"),
|
|
)
|
|
end
|
|
|
|
it "passes ip_address through to build_remote_http_client" do
|
|
allow(http_client).to receive(:start).and_raise(StandardError.new("test"))
|
|
|
|
begin
|
|
federation_helpers.send(:perform_single_http_request, uri, ip_address: "198.51.100.7")
|
|
rescue PotatoMesh::App::InstanceFetchError
|
|
# expected
|
|
end
|
|
|
|
expect(federation_helpers).to have_received(:build_remote_http_client).with(uri, ip_address: "198.51.100.7")
|
|
end
|
|
end
|
|
|
|
describe ".perform_instance_http_request" do
|
|
let(:uri) { URI.parse("https://remote.example.com/api") }
|
|
let(:v4_addr) { IPAddr.new("203.0.113.5") }
|
|
let(:v6_addr) { IPAddr.new("2001:db8::1") }
|
|
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(5)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(10)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_request_timeout).and_return(15)
|
|
end
|
|
|
|
it "succeeds on the first address" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v4_addr])
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.with(uri, ip_address: "203.0.113.5")
|
|
.and_return("{}")
|
|
|
|
result = federation_helpers.send(:perform_instance_http_request, uri)
|
|
|
|
expect(result).to eq("{}")
|
|
end
|
|
|
|
it "falls back to the next address on ECONNREFUSED" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.with(uri, ip_address: "2001:db8::1")
|
|
.and_raise(PotatoMesh::App::InstanceFetchError.new("Errno::ECONNREFUSED: Connection refused"))
|
|
allow(federation_helpers).to receive(:connection_refused_or_unreachable?).and_return(true)
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.with(uri, ip_address: "203.0.113.5")
|
|
.and_return('{"ok":true}')
|
|
|
|
result = federation_helpers.send(:perform_instance_http_request, uri)
|
|
|
|
expect(result).to eq('{"ok":true}')
|
|
end
|
|
|
|
it "raises after all addresses fail with connection errors" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
allow(federation_helpers).to receive(:connection_refused_or_unreachable?).and_return(true)
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.and_raise(PotatoMesh::App::InstanceFetchError.new("Errno::ECONNREFUSED: Connection refused"))
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, /ECONNREFUSED/)
|
|
end
|
|
|
|
it "raises immediately on non-connection errors without trying further addresses" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
ssl_error = PotatoMesh::App::InstanceFetchError.new("OpenSSL::SSL::SSLError: handshake failed")
|
|
# After sorting, IPv4 (203.0.113.5) is tried first
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.with(uri, ip_address: "203.0.113.5")
|
|
.and_raise(ssl_error)
|
|
allow(federation_helpers).to receive(:connection_refused_or_unreachable?).and_return(false)
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, /SSLError/)
|
|
|
|
expect(federation_helpers).not_to have_received(:perform_single_http_request)
|
|
.with(uri, ip_address: "2001:db8::1")
|
|
end
|
|
|
|
it "stops iterating when shutdown is requested" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
call_count = 0
|
|
allow(federation_helpers).to receive(:perform_single_http_request) do
|
|
call_count += 1
|
|
federation_helpers.request_federation_shutdown!
|
|
raise PotatoMesh::App::InstanceFetchError, "refused"
|
|
end
|
|
allow(federation_helpers).to receive(:connection_refused_or_unreachable?).and_return(true)
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError)
|
|
|
|
expect(call_count).to eq(1)
|
|
end
|
|
|
|
it "falls back to address-less request when resolution returns no results" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([])
|
|
allow(federation_helpers).to receive(:perform_single_http_request)
|
|
.with(uri, ip_address: nil)
|
|
.and_return("{}")
|
|
|
|
result = federation_helpers.send(:perform_instance_http_request, uri)
|
|
|
|
expect(result).to eq("{}")
|
|
end
|
|
|
|
it "tries IPv4 addresses before IPv6" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
call_order = []
|
|
allow(federation_helpers).to receive(:perform_single_http_request) do |_uri, ip_address:|
|
|
call_order << ip_address
|
|
raise PotatoMesh::App::InstanceFetchError, "refused"
|
|
end
|
|
allow(federation_helpers).to receive(:connection_refused_or_unreachable?).and_return(true)
|
|
|
|
begin
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
rescue PotatoMesh::App::InstanceFetchError
|
|
# expected
|
|
end
|
|
|
|
expect(call_order).to eq(["203.0.113.5", "2001:db8::1"])
|
|
end
|
|
|
|
it "wraps restricted address resolution failures" do
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
|
|
end
|
|
|
|
it "wraps DNS resolution failures so a peer with an unresolvable domain does not 500" do
|
|
# Addrinfo.getaddrinfo raises Socket::ResolutionError (a SocketError) when
|
|
# the peer domain does not resolve. It must be wrapped as
|
|
# InstanceFetchError — like the restricted-address ArgumentError above — so
|
|
# the fetch path rejects the peer gracefully instead of bubbling a raw
|
|
# SocketError up to a 500 response.
|
|
allow(Addrinfo).to receive(:getaddrinfo)
|
|
.and_raise(Socket::ResolutionError.new("getaddrinfo: Name or service not known"))
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_instance_http_request, uri)
|
|
end.to raise_error(PotatoMesh::App::InstanceFetchError, /Name or service not known/)
|
|
end
|
|
end
|
|
|
|
describe ".federation_sleep_with_shutdown" do
|
|
it "returns false when shutdown is requested during sleep" do
|
|
allow(Kernel).to receive(:sleep)
|
|
call_count = 0
|
|
allow(federation_helpers).to receive(:federation_shutdown_requested?) do
|
|
call_count += 1
|
|
call_count > 1
|
|
end
|
|
|
|
result = federation_helpers.federation_sleep_with_shutdown(1.0)
|
|
|
|
expect(result).to be(false)
|
|
expect(Kernel).to have_received(:sleep).at_least(:once)
|
|
end
|
|
|
|
it "returns true when the full delay elapses without shutdown" do
|
|
allow(Kernel).to receive(:sleep)
|
|
allow(federation_helpers).to receive(:federation_shutdown_requested?).and_return(false)
|
|
|
|
result = federation_helpers.federation_sleep_with_shutdown(0.01)
|
|
|
|
expect(result).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".instance_announcement_payload" do
|
|
it "includes node count fields when present" do
|
|
attributes = {
|
|
id: "test-id",
|
|
domain: "test.mesh",
|
|
pubkey: "key",
|
|
name: "Test",
|
|
version: "1.0",
|
|
channel: "#ch",
|
|
frequency: "868",
|
|
latitude: 50.0,
|
|
longitude: 10.0,
|
|
last_update_time: Time.now.to_i,
|
|
is_private: false,
|
|
contact_link: nil,
|
|
nodes_count: 42,
|
|
meshcore_nodes_count: 30,
|
|
meshtastic_nodes_count: 12,
|
|
reticulum_nodes_count: 0,
|
|
}
|
|
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
|
|
expect(payload["nodes_count"]).to eq(42)
|
|
expect(payload["meshcore_nodes_count"]).to eq(30)
|
|
expect(payload["meshtastic_nodes_count"]).to eq(12)
|
|
# v2 wire is snake_case with a forward-compat reticulum count + version marker.
|
|
expect(payload["reticulum_nodes_count"]).to eq(0)
|
|
expect(payload["public_key"]).to eq("key")
|
|
expect(payload["signature_version"]).to eq(2)
|
|
expect(payload).not_to have_key("nodesCount")
|
|
end
|
|
|
|
it "omits node count fields when nil" do
|
|
attributes = {
|
|
id: "test-id",
|
|
domain: "test.mesh",
|
|
pubkey: "key",
|
|
name: "Test",
|
|
version: "1.0",
|
|
channel: "#ch",
|
|
frequency: "868",
|
|
latitude: 50.0,
|
|
longitude: 10.0,
|
|
last_update_time: Time.now.to_i,
|
|
is_private: false,
|
|
contact_link: nil,
|
|
}
|
|
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
|
|
expect(payload).not_to have_key("nodesCount")
|
|
expect(payload).not_to have_key("meshcoreNodesCount")
|
|
expect(payload).not_to have_key("meshtasticNodesCount")
|
|
end
|
|
end
|
|
|
|
describe ".perform_announce_request" do
|
|
let(:uri) { URI.parse("https://remote.mesh/api/instances") }
|
|
let(:payload) { '{"id":"test"}' }
|
|
let(:v4_addr) { IPAddr.new("203.0.113.5") }
|
|
let(:v6_addr) { IPAddr.new("2001:db8::1") }
|
|
let(:success_response) { Net::HTTPOK.new("1.1", "200", "OK") }
|
|
|
|
before do
|
|
allow(success_response).to receive(:code).and_return("200")
|
|
end
|
|
|
|
it "succeeds on the first resolved address" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v4_addr])
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: "203.0.113.5")
|
|
.and_return(success_response)
|
|
|
|
result = federation_helpers.send(:perform_announce_request, uri, payload)
|
|
|
|
expect(result).to eq(success_response)
|
|
end
|
|
|
|
it "falls back to the next address on ECONNREFUSED" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
# After sorting, IPv4 is tried first
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: "203.0.113.5")
|
|
.and_raise(Errno::ECONNREFUSED.new("refused"))
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: "2001:db8::1")
|
|
.and_return(success_response)
|
|
|
|
result = federation_helpers.send(:perform_announce_request, uri, payload)
|
|
|
|
expect(result).to eq(success_response)
|
|
end
|
|
|
|
it "raises after all addresses fail" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.and_raise(Errno::ECONNREFUSED.new("refused"))
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_announce_request, uri, payload)
|
|
end.to raise_error(Errno::ECONNREFUSED)
|
|
end
|
|
|
|
it "raises immediately on non-connection errors" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([v6_addr, v4_addr])
|
|
# After sorting, IPv4 (203.0.113.5) is tried first
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: "203.0.113.5")
|
|
.and_raise(OpenSSL::SSL::SSLError.new("handshake failed"))
|
|
|
|
expect do
|
|
federation_helpers.send(:perform_announce_request, uri, payload)
|
|
end.to raise_error(OpenSSL::SSL::SSLError)
|
|
|
|
expect(federation_helpers).not_to have_received(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: "2001:db8::1")
|
|
end
|
|
|
|
it "falls back to address-less request when resolution returns no results" do
|
|
allow(federation_helpers).to receive(:resolve_remote_ip_addresses).and_return([])
|
|
allow(federation_helpers).to receive(:perform_single_announce_request)
|
|
.with(uri, payload, ip_address: nil)
|
|
.and_return(success_response)
|
|
|
|
result = federation_helpers.send(:perform_announce_request, uri, payload)
|
|
|
|
expect(result).to eq(success_response)
|
|
end
|
|
end
|
|
|
|
describe ".announce_instance_to_domain" do
|
|
let(:payload) { "{}" }
|
|
let(:https_uri) { URI.parse("https://remote.mesh/api/instances") }
|
|
let(:http_uri) { URI.parse("http://remote.mesh/api/instances") }
|
|
let(:success_response) { Net::HTTPOK.new("1.1", "200", "OK") }
|
|
|
|
before do
|
|
allow(success_response).to receive(:code).and_return("200")
|
|
end
|
|
|
|
it "retries over HTTP when HTTPS connections are refused" do
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(https_uri, payload)
|
|
.and_raise(Errno::ECONNREFUSED.new("refused"))
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(http_uri, payload)
|
|
.and_return(success_response)
|
|
|
|
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
|
|
|
|
expect(result).to be(true)
|
|
expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP")
|
|
expect(federation_helpers.warn_messages).to be_empty
|
|
end
|
|
|
|
it "logs a warning when HTTPS refusal persists after HTTP fallback" do
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(https_uri, payload)
|
|
.and_raise(Errno::ECONNREFUSED.new("refused"))
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(http_uri, payload)
|
|
.and_raise(SocketError.new("dns failure"))
|
|
|
|
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
|
|
|
|
expect(result).to be(false)
|
|
expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP")
|
|
expect(
|
|
federation_helpers.warn_messages.count { |message| message.include?("Federation announcement raised exception") },
|
|
).to eq(2)
|
|
end
|
|
|
|
it "logs success when the announcement is published" do
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(https_uri, payload)
|
|
.and_return(success_response)
|
|
|
|
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
|
|
|
|
expect(result).to be(true)
|
|
expect(federation_helpers.debug_messages).to include("Published federation announcement")
|
|
end
|
|
|
|
it "does not fall back to HTTP after HTTPS returned an HTTP response" do
|
|
bad_response = Net::HTTPBadRequest.new("1.1", "400", "Bad Request")
|
|
allow(bad_response).to receive(:code).and_return("400")
|
|
expect(federation_helpers).to receive(:perform_announce_request)
|
|
.with(https_uri, payload)
|
|
.and_return(bad_response)
|
|
expect(federation_helpers).not_to receive(:perform_announce_request)
|
|
.with(http_uri, payload)
|
|
|
|
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
|
|
|
|
expect(result).to be(false)
|
|
expect(federation_helpers.warn_messages).to be_empty
|
|
end
|
|
end
|
|
|
|
describe ".ensure_federation_worker_pool!" do
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:federation_worker_pool_size).and_return(1)
|
|
allow(PotatoMesh::Config).to receive(:federation_worker_queue_capacity).and_return(1)
|
|
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.05)
|
|
end
|
|
|
|
it "returns nil when federation is disabled" do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(false)
|
|
|
|
expect(federation_helpers.ensure_federation_worker_pool!).to be_nil
|
|
end
|
|
|
|
it "returns nil when federation shutdown has been requested" do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
federation_helpers.request_federation_shutdown!
|
|
|
|
expect(federation_helpers.ensure_federation_worker_pool!).to be_nil
|
|
expect(federation_helpers.send(:settings).federation_worker_pool).to be_nil
|
|
end
|
|
|
|
it "creates and memoizes the worker pool" do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
|
|
pool = federation_helpers.ensure_federation_worker_pool!
|
|
expect(pool).to be_a(PotatoMesh::App::WorkerPool)
|
|
expect(federation_helpers.ensure_federation_worker_pool!).to equal(pool)
|
|
ensure
|
|
pool&.shutdown(timeout: 0.05)
|
|
federation_helpers.set(:federation_worker_pool, nil)
|
|
end
|
|
end
|
|
|
|
describe ".ensure_federation_shutdown_hook!" do
|
|
it "registers a single at_exit hook when called repeatedly" do
|
|
allow(federation_helpers).to receive(:at_exit)
|
|
|
|
federation_helpers.ensure_federation_shutdown_hook!
|
|
federation_helpers.ensure_federation_shutdown_hook!
|
|
|
|
expect(federation_helpers).to have_received(:at_exit).once
|
|
expect(federation_helpers.send(:settings).federation_shutdown_hook_installed).to be(true)
|
|
end
|
|
|
|
it "delegates hook installation from instances to the application class" do
|
|
class_with_instance = Class.new do
|
|
include PotatoMesh::App::Federation
|
|
end
|
|
|
|
expect(class_with_instance).to receive(:ensure_federation_shutdown_hook!).once
|
|
class_with_instance.new.ensure_federation_shutdown_hook!
|
|
end
|
|
|
|
it "uses ivar guard when hook-installed setting is unavailable" do
|
|
helper_without_hook_setting = Class.new do
|
|
extend PotatoMesh::App::Federation
|
|
|
|
class << self
|
|
def settings
|
|
@settings ||= Struct.new(:federation_thread, :initial_federation_thread, :federation_worker_pool, :federation_shutdown_requested).new
|
|
end
|
|
|
|
# No-op in this helper because tests only assert hook registration behavior.
|
|
def shutdown_federation_background_work!(timeout: nil); end
|
|
end
|
|
end
|
|
|
|
allow(helper_without_hook_setting).to receive(:at_exit)
|
|
helper_without_hook_setting.ensure_federation_shutdown_hook!
|
|
helper_without_hook_setting.ensure_federation_shutdown_hook!
|
|
|
|
expect(helper_without_hook_setting).to have_received(:at_exit).once
|
|
expect(
|
|
helper_without_hook_setting.instance_variable_get(:@federation_shutdown_hook_installed),
|
|
).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".stop_federation_thread!" do
|
|
it "wakes, joins, and kills a stubborn live thread" do
|
|
thread = instance_double(Thread)
|
|
allow(thread).to receive(:alive?).and_return(true, true, false)
|
|
allow(thread).to receive(:respond_to?).with(:wakeup).and_return(true)
|
|
allow(thread).to receive(:wakeup).and_raise(ThreadError, "not asleep")
|
|
allow(thread).to receive(:join)
|
|
allow(thread).to receive(:kill)
|
|
|
|
federation_helpers.set(:federation_thread, thread)
|
|
federation_helpers.stop_federation_thread!(:federation_thread, timeout: 0.01)
|
|
|
|
expect(thread).to have_received(:join).with(0.01)
|
|
expect(thread).to have_received(:kill)
|
|
expect(federation_helpers.send(:settings).federation_thread).to be_nil
|
|
end
|
|
end
|
|
|
|
describe ".shutdown_federation_worker_pool!" do
|
|
it "logs an error when shutdown fails" do
|
|
pool = instance_double(PotatoMesh::App::WorkerPool)
|
|
allow(pool).to receive(:shutdown).and_raise(StandardError, "boom")
|
|
|
|
federation_helpers.set(:federation_worker_pool, pool)
|
|
federation_helpers.shutdown_federation_worker_pool!
|
|
|
|
expect(federation_helpers.warn_messages.last).to include("Failed to shut down federation worker pool")
|
|
expect(federation_helpers.send(:settings).federation_worker_pool).to be_nil
|
|
end
|
|
|
|
it "uses federation_shutdown_timeout_seconds (not the task timeout) and arms force_kill_after" do
|
|
pool = instance_double(PotatoMesh::App::WorkerPool)
|
|
allow(PotatoMesh::Config).to receive(:federation_shutdown_timeout_seconds).and_return(3)
|
|
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(120)
|
|
expect(pool).to receive(:shutdown).with(
|
|
timeout: 3,
|
|
force_kill_after: 3,
|
|
)
|
|
|
|
federation_helpers.set(:federation_worker_pool, pool)
|
|
federation_helpers.shutdown_federation_worker_pool!
|
|
end
|
|
end
|
|
|
|
describe ".enqueue_federation_crawl" do
|
|
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
|
|
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:federation_crawl_cooldown_seconds).and_return(300)
|
|
end
|
|
|
|
it "returns false and logs when the pool is unavailable" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(nil)
|
|
|
|
result = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
expect(federation_helpers.debug_messages.last).to include("Skipped remote instance crawl")
|
|
end
|
|
|
|
it "returns false and logs when the domain is invalid" do
|
|
result = federation_helpers.enqueue_federation_crawl(
|
|
"https://bad domain",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
expect(federation_helpers.warn_messages.last).to include("Skipped remote instance crawl")
|
|
end
|
|
|
|
it "schedules ingestion work on the pool" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
db = instance_double(SQLite3::Database)
|
|
allow(db).to receive(:close)
|
|
|
|
expect(federation_helpers).to receive(:open_database).and_return(db)
|
|
expect(federation_helpers).to receive(:ingest_known_instances_from!).with(
|
|
db,
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
|
|
task = instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
expect(pool).to receive(:schedule) do |&block|
|
|
block.call
|
|
task
|
|
end
|
|
|
|
result = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
|
|
expect(result).to be(true)
|
|
expect(db).to have_received(:close)
|
|
end
|
|
|
|
it "logs when the worker queue is saturated" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::QueueFullError, "full")
|
|
expect(federation_helpers).to receive(:warn_log).with(
|
|
"Skipped remote instance crawl",
|
|
hash_including(
|
|
context: "federation.instances",
|
|
domain: "remote.mesh",
|
|
reason: "worker queue saturated",
|
|
),
|
|
).and_call_original
|
|
|
|
result = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
end
|
|
|
|
it "does not apply cooldown when scheduling fails due to queue saturation" do
|
|
allow(PotatoMesh::Config).to receive(:federation_crawl_cooldown_seconds).and_return(300)
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::QueueFullError, "full")
|
|
|
|
first = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
second = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
|
|
expect(first).to be(false)
|
|
expect(second).to be(false)
|
|
expect(federation_helpers.debug_messages).not_to include(
|
|
a_string_including("recent crawl completed"),
|
|
)
|
|
end
|
|
|
|
it "logs when the worker pool is shutting down" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::ShutdownError, "closed")
|
|
expect(federation_helpers).to receive(:warn_log).with(
|
|
"Skipped remote instance crawl",
|
|
hash_including(
|
|
context: "federation.instances",
|
|
domain: "remote.mesh",
|
|
reason: "worker pool shut down",
|
|
),
|
|
).and_call_original
|
|
|
|
result = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
end
|
|
|
|
it "deduplicates crawls while a domain crawl is already in flight" do
|
|
db = instance_double(SQLite3::Database)
|
|
allow(db).to receive(:close)
|
|
captured_job = nil
|
|
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule) do |&block|
|
|
captured_job = block
|
|
instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
end
|
|
allow(federation_helpers).to receive(:open_database).and_return(db)
|
|
allow(federation_helpers).to receive(:ingest_known_instances_from!)
|
|
|
|
first = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
second = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
|
|
expect(first).to be(true)
|
|
expect(second).to be(false)
|
|
expect(captured_job).not_to be_nil
|
|
captured_job.call
|
|
expect(db).to have_received(:close)
|
|
end
|
|
|
|
it "releases the crawl slot when opening the database fails" do
|
|
allow(federation_helpers).to receive(:federation_crawl_cooldown_seconds).and_return(0)
|
|
captured_job = nil
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule) do |&block|
|
|
captured_job = block
|
|
instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
end
|
|
allow(federation_helpers).to receive(:open_database).and_raise(SQLite3::Exception, "db unavailable")
|
|
allow(federation_helpers).to receive(:ingest_known_instances_from!)
|
|
|
|
first = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
expect(first).to be(true)
|
|
expect(captured_job).not_to be_nil
|
|
|
|
expect { captured_job.call }.to raise_error(SQLite3::Exception, "db unavailable")
|
|
|
|
second = federation_helpers.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 5,
|
|
overall_limit: 9,
|
|
)
|
|
expect(second).to be(true)
|
|
end
|
|
|
|
it "deduplicates crawls across instance receivers using shared class state" do
|
|
helper_class = Class.new do
|
|
include PotatoMesh::App::Federation
|
|
|
|
class << self
|
|
attr_accessor :pool
|
|
|
|
def settings
|
|
@settings ||= Struct.new(:federation_shutdown_requested).new(false)
|
|
end
|
|
|
|
def set(key, value)
|
|
settings.public_send("#{key}=", value)
|
|
end
|
|
|
|
def federation_worker_pool
|
|
pool
|
|
end
|
|
|
|
# No-op to keep the test helper minimal while satisfying federation logging calls.
|
|
def debug_log(*); end
|
|
|
|
# No-op to keep the test helper minimal while satisfying federation logging calls.
|
|
def warn_log(*); end
|
|
end
|
|
|
|
def settings
|
|
self.class.settings
|
|
end
|
|
|
|
def set(key, value)
|
|
self.class.set(key, value)
|
|
end
|
|
|
|
def debug_log(...)
|
|
self.class.debug_log(...)
|
|
end
|
|
|
|
def warn_log(...)
|
|
self.class.warn_log(...)
|
|
end
|
|
end
|
|
|
|
pool_double = instance_double(PotatoMesh::App::WorkerPool)
|
|
allow(pool_double).to receive(:schedule).and_return(instance_double(PotatoMesh::App::WorkerPool::Task))
|
|
helper_class.pool = pool_double
|
|
|
|
first_receiver = helper_class.new
|
|
second_receiver = helper_class.new
|
|
|
|
first = first_receiver.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
second = second_receiver.enqueue_federation_crawl(
|
|
"remote.mesh",
|
|
per_response_limit: 1,
|
|
overall_limit: 2,
|
|
)
|
|
|
|
expect(first).to be(true)
|
|
expect(second).to be(false)
|
|
expect(pool_double).to have_received(:schedule).once
|
|
end
|
|
end
|
|
|
|
describe ".fetch_instance_json" do
|
|
it "short-circuits when shutdown has been requested" do
|
|
federation_helpers.request_federation_shutdown!
|
|
|
|
payload, metadata = federation_helpers.fetch_instance_json("remote.mesh", NODES_API_PATH)
|
|
|
|
expect(payload).to be_nil
|
|
expect(metadata).to eq(["federation shutdown requested"])
|
|
end
|
|
|
|
it "stops iterating URI candidates after shutdown is requested mid-loop" do
|
|
calls = 0
|
|
allow(federation_helpers).to receive(:instance_uri_candidates).and_return([
|
|
URI.parse("https://remote.mesh/api/nodes"),
|
|
URI.parse("http://remote.mesh/api/nodes"),
|
|
])
|
|
allow(federation_helpers).to receive(:perform_instance_http_request) do |_uri|
|
|
calls += 1
|
|
federation_helpers.request_federation_shutdown!
|
|
raise PotatoMesh::App::InstanceFetchError, "boom"
|
|
end
|
|
|
|
payload, metadata = federation_helpers.fetch_instance_json("remote.mesh", NODES_API_PATH)
|
|
|
|
expect(payload).to be_nil
|
|
expect(calls).to eq(1)
|
|
expect(metadata.first).to include("boom")
|
|
end
|
|
|
|
it "returns [nil, errors] instead of raising when the peer domain fails DNS resolution" do
|
|
# Regression for the production 500: a registering/crawled peer whose
|
|
# domain does not resolve must surface as a recorded fetch error (so the
|
|
# caller can reject it with a 4xx), never escape as an unhandled SocketError.
|
|
allow(Addrinfo).to receive(:getaddrinfo)
|
|
.and_raise(Socket::ResolutionError.new("getaddrinfo: Name or service not known"))
|
|
|
|
payload, metadata = federation_helpers.fetch_instance_json("does-not-resolve.invalid", NODES_API_PATH)
|
|
|
|
expect(payload).to be_nil
|
|
expect(metadata).not_to be_empty
|
|
expect(metadata.join("; ")).to include("Name or service not known")
|
|
end
|
|
|
|
it "does not fall back to HTTP after HTTPS returned an HTTP response" do
|
|
calls = []
|
|
allow(federation_helpers).to receive(:instance_uri_candidates).and_return([
|
|
URI.parse("https://remote.mesh/api/nodes"),
|
|
URI.parse("http://remote.mesh/api/nodes"),
|
|
])
|
|
allow(federation_helpers).to receive(:perform_instance_http_request) do |uri|
|
|
calls << uri.scheme
|
|
raise PotatoMesh::App::InstanceHttpResponseError, "unexpected response 404"
|
|
end
|
|
|
|
payload, metadata = federation_helpers.fetch_instance_json("remote.mesh", NODES_API_PATH)
|
|
|
|
expect(payload).to be_nil
|
|
expect(calls).to eq(["https"])
|
|
expect(metadata.first).to include("unexpected response 404")
|
|
end
|
|
|
|
it "still falls back to HTTP when HTTPS connection itself fails" do
|
|
calls = []
|
|
allow(federation_helpers).to receive(:instance_uri_candidates).and_return([
|
|
URI.parse("https://remote.mesh/api/nodes"),
|
|
URI.parse("http://remote.mesh/api/nodes"),
|
|
])
|
|
allow(federation_helpers).to receive(:perform_instance_http_request) do |uri|
|
|
calls << uri.scheme
|
|
if uri.scheme == "https"
|
|
raise PotatoMesh::App::InstanceFetchError, "Errno::ECONNREFUSED: refused"
|
|
else
|
|
'{"ok":true}'
|
|
end
|
|
end
|
|
|
|
payload, _ = federation_helpers.fetch_instance_json("remote.mesh", NODES_API_PATH)
|
|
|
|
expect(payload).to eq({ "ok" => true })
|
|
expect(calls).to eq(["https", "http"])
|
|
end
|
|
end
|
|
|
|
describe ".claim_federation_crawl_slot" do
|
|
it "initializes crawl dedupe state safely under concurrent access" do
|
|
federation_helpers.instance_variable_set(:@federation_crawl_mutex, nil)
|
|
federation_helpers.instance_variable_set(:@federation_crawl_in_flight, nil)
|
|
federation_helpers.instance_variable_set(:@federation_crawl_last_completed_at, nil)
|
|
federation_helpers.instance_variable_set(:@federation_crawl_init_mutex, nil)
|
|
|
|
threads = Array.new(12) do
|
|
Thread.new do
|
|
federation_helpers.initialize_federation_crawl_state!
|
|
end
|
|
end
|
|
threads.each(&:join)
|
|
|
|
mutex = federation_helpers.instance_variable_get(:@federation_crawl_mutex)
|
|
in_flight = federation_helpers.instance_variable_get(:@federation_crawl_in_flight)
|
|
last_completed = federation_helpers.instance_variable_get(:@federation_crawl_last_completed_at)
|
|
|
|
expect(mutex).to be_a(Mutex)
|
|
expect(in_flight).to be_a(Set)
|
|
expect(last_completed).to be_a(Hash)
|
|
expect(in_flight).to be_empty
|
|
expect(last_completed).to be_empty
|
|
end
|
|
|
|
it "returns cooldown when the domain completed recently" do
|
|
allow(PotatoMesh::Config).to receive(:federation_crawl_cooldown_seconds).and_return(300)
|
|
federation_helpers.clear_federation_crawl_state!
|
|
federation_helpers.release_federation_crawl_slot("remote.mesh")
|
|
|
|
result = federation_helpers.claim_federation_crawl_slot("remote.mesh")
|
|
|
|
expect(result).to eq(:cooldown)
|
|
end
|
|
end
|
|
|
|
describe ".shutdown_federation_background_work!" do
|
|
it "marks shutdown and clears announcer references" do
|
|
initial_thread = instance_double(Thread)
|
|
recurring_thread = instance_double(Thread)
|
|
pool = instance_double(PotatoMesh::App::WorkerPool)
|
|
allow(PotatoMesh::Config).to receive(:federation_shutdown_timeout_seconds).and_return(0.05)
|
|
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.05)
|
|
|
|
[initial_thread, recurring_thread].each do |thread|
|
|
allow(thread).to receive(:alive?).and_return(false)
|
|
end
|
|
allow(pool).to receive(:shutdown)
|
|
|
|
federation_helpers.set(:initial_federation_thread, initial_thread)
|
|
federation_helpers.set(:federation_thread, recurring_thread)
|
|
federation_helpers.set(:federation_worker_pool, pool)
|
|
|
|
federation_helpers.shutdown_federation_background_work!
|
|
|
|
expect(federation_helpers.federation_shutdown_requested?).to be(true)
|
|
expect(federation_helpers.send(:settings).initial_federation_thread).to be_nil
|
|
expect(federation_helpers.send(:settings).federation_thread).to be_nil
|
|
expect(federation_helpers.send(:settings).federation_worker_pool).to be_nil
|
|
end
|
|
end
|
|
|
|
describe ".wait_for_federation_tasks" do
|
|
it "does nothing for empty input" do
|
|
federation_helpers.wait_for_federation_tasks([])
|
|
expect(federation_helpers.warn_messages).to be_empty
|
|
end
|
|
|
|
it "logs timeouts" do
|
|
task = instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
allow(task).to receive(:wait).and_raise(PotatoMesh::App::WorkerPool::TaskTimeoutError, "late")
|
|
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.01)
|
|
|
|
federation_helpers.wait_for_federation_tasks([["remote.mesh", task]])
|
|
|
|
expect(federation_helpers.warn_messages.last).to include("task timed out")
|
|
end
|
|
|
|
it "logs unexpected failures" do
|
|
task = instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
allow(task).to receive(:wait).and_raise(RuntimeError, "boom")
|
|
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.01)
|
|
|
|
federation_helpers.wait_for_federation_tasks([["remote.mesh", task]])
|
|
|
|
expect(federation_helpers.warn_messages.last).to include("task failed")
|
|
end
|
|
end
|
|
|
|
describe ".announce_instance_to_all_domains" do
|
|
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
|
|
|
|
before do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:ensure_self_instance_record!).and_return([
|
|
{ domain: "self.mesh" },
|
|
"signature",
|
|
])
|
|
allow(federation_helpers).to receive(:instance_announcement_payload).and_return({})
|
|
allow(JSON).to receive(:generate).and_return("payload-json")
|
|
allow(federation_helpers).to receive(:federation_target_domains).and_return(%w[alpha.mesh beta.mesh])
|
|
end
|
|
|
|
it "schedules announcements on the worker pool" do
|
|
task = instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
allow(federation_helpers).to receive(:wait_for_federation_tasks)
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
expect(pool).to receive(:schedule).twice.and_return(task)
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
end
|
|
|
|
it "falls back to synchronous announcements when the queue is saturated" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::QueueFullError, "full")
|
|
allow(federation_helpers).to receive(:wait_for_federation_tasks)
|
|
expect(federation_helpers).to receive(:announce_instance_to_domain).with("alpha.mesh", "payload-json").once
|
|
expect(federation_helpers).to receive(:announce_instance_to_domain).with("beta.mesh", "payload-json").once
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
end
|
|
|
|
it "runs synchronously when the worker pool is unavailable" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(nil)
|
|
expect(federation_helpers).to receive(:announce_instance_to_domain).with("alpha.mesh", "payload-json")
|
|
expect(federation_helpers).to receive(:announce_instance_to_domain).with("beta.mesh", "payload-json")
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
end
|
|
|
|
it "logs cycle start and end at info level with target and result counts" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(nil)
|
|
allow(federation_helpers).to receive(:announce_instance_to_domain)
|
|
.with("alpha.mesh", "payload-json").and_return(true)
|
|
allow(federation_helpers).to receive(:announce_instance_to_domain)
|
|
.with("beta.mesh", "payload-json").and_return(false)
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
|
|
messages = federation_helpers.info_messages
|
|
start_entry = messages.find { |(msg, _)| msg.include?("cycle started") }
|
|
end_entry = messages.find { |(msg, _)| msg.include?("cycle complete") }
|
|
|
|
expect(start_entry).not_to be_nil
|
|
expect(start_entry.last[:target_count]).to eq(2)
|
|
|
|
expect(end_entry).not_to be_nil
|
|
expect(end_entry.last[:success_count]).to eq(1)
|
|
expect(end_entry.last[:failure_count]).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe ".fetch_instance_json network timeout and malformed response" do
|
|
let(:uri_https) { URI.parse("https://remote.example.com/api/nodes") }
|
|
let(:uri_http) { URI.parse("http://remote.example.com/api/nodes") }
|
|
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(5)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(10)
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_request_timeout).and_return(15)
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("203.0.113.5")])
|
|
end
|
|
|
|
it "returns nil and an error list when a network timeout occurs" do
|
|
# perform_instance_http_request wraps all StandardError subclasses
|
|
# (including Timeout::Error) into InstanceFetchError before returning to
|
|
# fetch_instance_json, which then records the message as an error entry.
|
|
allow(federation_helpers).to receive(:perform_instance_http_request).and_raise(
|
|
PotatoMesh::App::InstanceFetchError, "Timeout::Error: execution expired",
|
|
)
|
|
|
|
result, errors = federation_helpers.send(:fetch_instance_json, "remote.example.com", "/api/nodes")
|
|
|
|
expect(result).to be_nil
|
|
# At least one error entry should mention the timeout.
|
|
expect(errors).to be_an(Array)
|
|
expect(errors).not_to be_empty
|
|
expect(errors.any? { |e| e.include?("Timeout") || e.include?("expired") }).to be(true)
|
|
end
|
|
|
|
it "returns nil and an error list when a Net::OpenTimeout fires" do
|
|
# Net::OpenTimeout inherits from Timeout::Error; perform_instance_http_request
|
|
# wraps it into InstanceFetchError with the class name prepended.
|
|
allow(federation_helpers).to receive(:perform_instance_http_request).and_raise(
|
|
PotatoMesh::App::InstanceFetchError, "Net::OpenTimeout: Failed to open TCP connection",
|
|
)
|
|
|
|
result, errors = federation_helpers.send(:fetch_instance_json, "remote.example.com", "/api/nodes")
|
|
|
|
expect(result).to be_nil
|
|
expect(errors).not_to be_empty
|
|
expect(errors.any? { |e| e.include?("Timeout") || e.include?("TCP") }).to be(true)
|
|
end
|
|
|
|
it "returns nil and records the parse error when the remote responds with 200 but invalid JSON" do
|
|
# perform_instance_http_request returns the raw body string on success;
|
|
# fetch_instance_json then calls JSON.parse on it and must handle the
|
|
# JSON::ParserError gracefully.
|
|
allow(federation_helpers).to receive(:perform_instance_http_request).and_return(
|
|
"not valid { json [",
|
|
)
|
|
|
|
result, errors = federation_helpers.send(:fetch_instance_json, "remote.example.com", "/api/nodes")
|
|
|
|
expect(result).to be_nil
|
|
expect(errors).not_to be_empty
|
|
expect(errors.any? { |e| e.include?("invalid JSON") }).to be(true)
|
|
end
|
|
|
|
it "returns nil and records the parse error when the body is an empty string" do
|
|
# An empty body is not valid JSON; JSON.parse raises a ParserError.
|
|
allow(federation_helpers).to receive(:perform_instance_http_request).and_return("")
|
|
|
|
result, errors = federation_helpers.send(:fetch_instance_json, "remote.example.com", "/api/nodes")
|
|
|
|
expect(result).to be_nil
|
|
expect(errors).not_to be_empty
|
|
end
|
|
end
|
|
|
|
describe ".start_federation_announcer!" do
|
|
it "clears shutdown, installs hook, and exits loop when sleep aborts" do
|
|
thread_double = instance_double(Thread)
|
|
captured = nil
|
|
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:clear_federation_shutdown_request!)
|
|
allow(federation_helpers).to receive(:ensure_federation_shutdown_hook!)
|
|
allow(federation_helpers).to receive(:federation_sleep_with_shutdown).and_return(false)
|
|
allow(Thread).to receive(:new) do |&block|
|
|
captured = block
|
|
thread_double
|
|
end
|
|
allow(thread_double).to receive(:respond_to?).with(:name=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:daemon=).and_return(false)
|
|
allow(federation_helpers).to receive(:set)
|
|
|
|
result = federation_helpers.start_federation_announcer!
|
|
expect(result).to eq(thread_double)
|
|
expect(captured).to be_a(Proc)
|
|
captured.call
|
|
|
|
expect(federation_helpers).to have_received(:clear_federation_shutdown_request!)
|
|
expect(federation_helpers).to have_received(:ensure_federation_shutdown_hook!)
|
|
expect(federation_helpers).to have_received(:federation_sleep_with_shutdown)
|
|
end
|
|
|
|
it "logs and continues when announce raises in the loop" do
|
|
thread_double = instance_double(Thread)
|
|
captured = nil
|
|
sleep_results = [true, false]
|
|
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:clear_federation_shutdown_request!)
|
|
allow(federation_helpers).to receive(:ensure_federation_shutdown_hook!)
|
|
allow(federation_helpers).to receive(:federation_sleep_with_shutdown) { sleep_results.shift }
|
|
allow(federation_helpers).to receive(:announce_instance_to_all_domains).and_raise(RuntimeError, "boom")
|
|
allow(Thread).to receive(:new) do |&block|
|
|
captured = block
|
|
thread_double
|
|
end
|
|
allow(thread_double).to receive(:respond_to?).with(:name=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:daemon=).and_return(false)
|
|
allow(federation_helpers).to receive(:set)
|
|
|
|
federation_helpers.start_federation_announcer!
|
|
captured.call
|
|
|
|
expect(federation_helpers).to have_received(:announce_instance_to_all_domains).once
|
|
expect(federation_helpers.warn_messages.last).to include("Federation announcement loop error")
|
|
end
|
|
end
|
|
|
|
describe ".start_initial_federation_announcement!" do
|
|
it "does nothing when federation is disabled" do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(false)
|
|
|
|
expect(federation_helpers.start_initial_federation_announcement!).to be_nil
|
|
end
|
|
|
|
it "returns the existing thread when one is alive" do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:clear_federation_shutdown_request!)
|
|
allow(federation_helpers).to receive(:ensure_federation_shutdown_hook!)
|
|
existing = instance_double(Thread, alive?: true)
|
|
federation_helpers.set(:initial_federation_thread, existing)
|
|
|
|
expect(federation_helpers.start_initial_federation_announcement!).to equal(existing)
|
|
end
|
|
|
|
it "logs and clears the slot when the announcement raises" do
|
|
thread_double = instance_double(Thread)
|
|
captured = nil
|
|
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:clear_federation_shutdown_request!)
|
|
allow(federation_helpers).to receive(:ensure_federation_shutdown_hook!)
|
|
allow(PotatoMesh::Config).to receive(:initial_federation_delay_seconds).and_return(0)
|
|
allow(federation_helpers).to receive(:announce_instance_to_all_domains).and_raise(RuntimeError, "boom")
|
|
allow(Thread).to receive(:new) do |&block|
|
|
captured = block
|
|
thread_double
|
|
end
|
|
allow(thread_double).to receive(:respond_to?).with(:name=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:report_on_exception=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:daemon=).and_return(false)
|
|
|
|
federation_helpers.start_initial_federation_announcement!
|
|
captured.call
|
|
|
|
expect(federation_helpers.warn_messages.last).to include("Initial federation announcement failed")
|
|
expect(federation_helpers.send(:settings).initial_federation_thread).to be_nil
|
|
end
|
|
|
|
it "skips the announcement when shutdown is requested during the initial delay" do
|
|
thread_double = instance_double(Thread)
|
|
captured = nil
|
|
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:clear_federation_shutdown_request!)
|
|
allow(federation_helpers).to receive(:ensure_federation_shutdown_hook!)
|
|
allow(PotatoMesh::Config).to receive(:initial_federation_delay_seconds).and_return(0.01)
|
|
allow(federation_helpers).to receive(:federation_sleep_with_shutdown).and_return(false)
|
|
allow(federation_helpers).to receive(:announce_instance_to_all_domains)
|
|
allow(Thread).to receive(:new) do |&block|
|
|
captured = block
|
|
thread_double
|
|
end
|
|
allow(thread_double).to receive(:respond_to?).with(:name=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:report_on_exception=).and_return(false)
|
|
allow(thread_double).to receive(:respond_to?).with(:daemon=).and_return(false)
|
|
|
|
federation_helpers.start_initial_federation_announcement!
|
|
captured.call
|
|
|
|
expect(federation_helpers).not_to have_received(:announce_instance_to_all_domains)
|
|
end
|
|
end
|
|
|
|
describe ".announce_instance_to_domain (non-success branch)" do
|
|
it "logs the failure when the response is not a success" do
|
|
payload = "{}"
|
|
https_uri = URI.parse("https://remote.mesh/api/instances")
|
|
response = Net::HTTPInternalServerError.new("1.1", "500", "Server Error")
|
|
allow(response).to receive(:code).and_return("500")
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(https_uri, payload)
|
|
.and_return(response)
|
|
allow(federation_helpers).to receive(:perform_announce_request)
|
|
.with(URI.parse("http://remote.mesh/api/instances"), payload)
|
|
.and_return(response)
|
|
|
|
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
|
|
|
|
expect(result).to be(false)
|
|
expect(federation_helpers.debug_messages).to include("Federation announcement failed")
|
|
end
|
|
end
|
|
|
|
describe ".perform_single_announce_request" do
|
|
let(:uri) { URI.parse("https://remote.mesh/api/instances") }
|
|
let(:payload) { '{"id":"x"}' }
|
|
let(:http_client) { instance_double(Net::HTTP) }
|
|
|
|
before do
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_request_timeout).and_return(5)
|
|
allow(federation_helpers).to receive(:build_remote_http_client).and_return(http_client)
|
|
end
|
|
|
|
it "issues a POST through the connection block" do
|
|
connection = instance_double(HTTP_CONNECTION_DOUBLE)
|
|
response = Net::HTTPOK.new("1.1", "200", "OK")
|
|
allow(response).to receive(:code).and_return("200")
|
|
captured_request = nil
|
|
|
|
allow(http_client).to receive(:start) { |&block| block.call(connection) }
|
|
allow(connection).to receive(:request) do |request|
|
|
captured_request = request
|
|
response
|
|
end
|
|
|
|
result = federation_helpers.send(:perform_single_announce_request, uri, payload, ip_address: "203.0.113.5")
|
|
|
|
expect(result).to eq(response)
|
|
expect(captured_request).to be_a(Net::HTTP::Post)
|
|
expect(captured_request.body).to eq(payload)
|
|
expect(federation_helpers).to have_received(:build_remote_http_client).with(uri, ip_address: "203.0.113.5")
|
|
end
|
|
end
|
|
|
|
describe ".announce_instance_to_all_domains (extra branches)" do
|
|
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
|
|
|
|
before do
|
|
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
|
|
allow(federation_helpers).to receive(:ensure_self_instance_record!).and_return([
|
|
{ domain: "self.mesh" },
|
|
"signature",
|
|
])
|
|
allow(federation_helpers).to receive(:instance_announcement_payload).and_return({})
|
|
allow(JSON).to receive(:generate).and_return("payload-json")
|
|
allow(federation_helpers).to receive(:federation_target_domains).and_return(%w[alpha.mesh beta.mesh])
|
|
allow(federation_helpers).to receive(:wait_for_federation_tasks)
|
|
end
|
|
|
|
it "executes the scheduled task block to announce on the pool" do
|
|
task = instance_double(PotatoMesh::App::WorkerPool::Task)
|
|
captured_blocks = []
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule) do |&block|
|
|
captured_blocks << block
|
|
task
|
|
end
|
|
allow(federation_helpers).to receive(:announce_instance_to_domain)
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
captured_blocks.each(&:call)
|
|
|
|
expect(federation_helpers).to have_received(:announce_instance_to_domain).with("alpha.mesh", "payload-json")
|
|
expect(federation_helpers).to have_received(:announce_instance_to_domain).with("beta.mesh", "payload-json")
|
|
end
|
|
|
|
it "falls back to synchronous announcements when the pool is shutting down" do
|
|
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
|
|
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::ShutdownError, "closed")
|
|
allow(federation_helpers).to receive(:announce_instance_to_domain)
|
|
|
|
federation_helpers.announce_instance_to_all_domains
|
|
|
|
expect(federation_helpers).to have_received(:announce_instance_to_domain).with("alpha.mesh", "payload-json")
|
|
expect(federation_helpers).to have_received(:announce_instance_to_domain).with("beta.mesh", "payload-json")
|
|
expect(federation_helpers.warn_messages).to include(a_string_including("Worker pool unavailable"))
|
|
end
|
|
end
|
|
|
|
describe ".remote_active_node_count_from_stats" do
|
|
let(:active) { { "hour" => 1, "day" => 2, "week" => 3, "month" => 4 } }
|
|
|
|
it "returns the day count for sub-day windows" do
|
|
payload = { "active_nodes" => active }
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: 7_200)).to eq(2)
|
|
end
|
|
|
|
it "returns the week count for sub-week windows" do
|
|
payload = { "active_nodes" => active }
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: 90_000)).to eq(3)
|
|
end
|
|
|
|
it "returns the month count for windows beyond a week" do
|
|
payload = { "active_nodes" => active }
|
|
window = PotatoMesh::Config.week_seconds + 60
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: window)).to eq(4)
|
|
end
|
|
|
|
it "reads the 0.7.0 total.nodes shape" do
|
|
payload = { "total" => { "nodes" => active } }
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: 7_200)).to eq(2)
|
|
end
|
|
|
|
it "prefers the new shape over a legacy active_nodes block" do
|
|
payload = {
|
|
"total" => { "nodes" => { "hour" => 1, "day" => 8, "week" => 9, "month" => 10 } },
|
|
"active_nodes" => active,
|
|
}
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: 7_200)).to eq(8)
|
|
end
|
|
|
|
it "returns nil when neither stats shape is present" do
|
|
payload = { "sampled" => false }
|
|
expect(federation_helpers.remote_active_node_count_from_stats(payload, max_age_seconds: 7_200)).to be_nil
|
|
end
|
|
end
|
|
|
|
describe ".remote_stats_protocol_day" do
|
|
it "reads the 0.7.0 nodes sub-hash" do
|
|
payload = { "meshcore" => { "nodes" => { "day" => 9 } } }
|
|
expect(federation_helpers.remote_stats_protocol_day(payload, "meshcore")).to eq(9)
|
|
end
|
|
|
|
it "falls back to the legacy flat day count" do
|
|
payload = { "meshtastic" => { "day" => 4 } }
|
|
expect(federation_helpers.remote_stats_protocol_day(payload, "meshtastic")).to eq(4)
|
|
end
|
|
|
|
it "returns nil when neither shape carries the protocol" do
|
|
expect(federation_helpers.remote_stats_protocol_day({}, "meshcore")).to be_nil
|
|
end
|
|
end
|
|
|
|
describe ".remote_instance_attributes_from_payload (extra branches)" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
|
|
it "honors the legacy is_private key when isPrivate is absent" do
|
|
payload = {
|
|
"id" => "remote-id",
|
|
"domain" => "remote.mesh",
|
|
"pubkey" => application_class::INSTANCE_PUBLIC_KEY_PEM,
|
|
"signature" => "sig",
|
|
"is_private" => true,
|
|
}
|
|
|
|
attributes, signature, reason = application_class.remote_instance_attributes_from_payload(payload)
|
|
|
|
expect(reason).to be_nil
|
|
expect(signature).to eq("sig")
|
|
expect(attributes[:is_private]).to be(true)
|
|
end
|
|
|
|
it "treats numeric is_private values as truthy when non-zero" do
|
|
payload = {
|
|
"id" => "remote-id",
|
|
"domain" => "remote.mesh",
|
|
"pubkey" => application_class::INSTANCE_PUBLIC_KEY_PEM,
|
|
"signature" => "sig",
|
|
"isPrivate" => 1,
|
|
}
|
|
|
|
attributes, _signature, _reason = application_class.remote_instance_attributes_from_payload(payload)
|
|
|
|
expect(attributes[:is_private]).to be(true)
|
|
end
|
|
|
|
it "wraps unexpected exceptions raised during parsing" do
|
|
payload = {
|
|
"id" => "remote-id",
|
|
"domain" => "remote.mesh",
|
|
"pubkey" => application_class::INSTANCE_PUBLIC_KEY_PEM,
|
|
"signature" => "sig",
|
|
}
|
|
allow(application_class).to receive(:coerce_float).and_raise(StandardError, "exploded")
|
|
|
|
attributes, signature, reason = application_class.remote_instance_attributes_from_payload(payload)
|
|
|
|
expect(attributes).to be_nil
|
|
expect(signature).to be_nil
|
|
expect(reason).to eq("exploded")
|
|
end
|
|
end
|
|
|
|
describe ".ingest_known_instances_from! (overall_limit short-circuit)" do
|
|
it "returns immediately when visited already meets the overall limit" do
|
|
visited = Set.new(%w[seed.mesh other.mesh])
|
|
result = federation_helpers.ingest_known_instances_from!(
|
|
:db,
|
|
"seed.mesh",
|
|
visited: visited,
|
|
per_response_limit: 5,
|
|
overall_limit: 2,
|
|
)
|
|
|
|
expect(result).to equal(visited)
|
|
expect(federation_helpers.debug_messages).to include(a_string_including("crawl limit"))
|
|
end
|
|
end
|
|
|
|
describe ".https_connection_refused? (extra branches)" do
|
|
it "returns false when no link in the cause chain is ECONNREFUSED" do
|
|
outer = begin
|
|
begin
|
|
raise StandardError, "inner"
|
|
rescue StandardError
|
|
raise StandardError, "outer"
|
|
end
|
|
rescue StandardError => e
|
|
e
|
|
end
|
|
|
|
expect(federation_helpers.send(:https_connection_refused?, outer)).to be(false)
|
|
end
|
|
|
|
it "walks the cause chain to find ECONNREFUSED" do
|
|
outer = begin
|
|
begin
|
|
raise Errno::ECONNREFUSED, "refused"
|
|
rescue Errno::ECONNREFUSED
|
|
raise StandardError, "wrapper"
|
|
end
|
|
rescue StandardError => e
|
|
e
|
|
end
|
|
|
|
expect(federation_helpers.send(:https_connection_refused?, outer)).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".instance_uri_candidates" do
|
|
it "returns an empty array when URI parsing fails" do
|
|
allow(URI).to receive(:parse).and_raise(URI::InvalidURIError, "bad")
|
|
|
|
expect(federation_helpers.send(:instance_uri_candidates, "remote.mesh", "/api/x")).to eq([])
|
|
end
|
|
end
|
|
|
|
describe ".resolve_remote_ip_addresses (invalid address skipping)" do
|
|
it "skips addrinfo entries that cannot be parsed as IPAddr" do
|
|
bogus = instance_double(Addrinfo, ip_address: "not-an-ip")
|
|
good = Addrinfo.ip("203.0.113.5")
|
|
allow(Addrinfo).to receive(:getaddrinfo).and_return([bogus, good])
|
|
|
|
uri = URI.parse("https://remote.mesh/api")
|
|
result = federation_helpers.send(:resolve_remote_ip_addresses, uri)
|
|
|
|
expect(result.map(&:to_s)).to eq(["203.0.113.5"])
|
|
end
|
|
end
|
|
|
|
describe ".remote_instance_verify_callback (failure path)" do
|
|
it "logs and clears the cached callback when creation raises" do
|
|
federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil)
|
|
allow(federation_helpers).to receive(:lambda).and_raise(StandardError, "boom")
|
|
|
|
expect(federation_helpers.remote_instance_verify_callback).to be_nil
|
|
expect(federation_helpers.debug_messages.last).to include("verify callback")
|
|
end
|
|
end
|
|
|
|
describe ".active_node_count_since (rescue path)" do
|
|
it "logs and returns nil when the database query raises" do
|
|
handle = instance_double(SQLite3::Database)
|
|
allow(handle).to receive(:close)
|
|
allow(federation_helpers).to receive(:open_database).and_return(handle)
|
|
allow(federation_helpers).to receive(:with_busy_retry).and_raise(SQLite3::Exception, "boom")
|
|
|
|
expect(federation_helpers.active_node_count_since(100)).to be_nil
|
|
expect(federation_helpers.warn_messages.last).to include("Failed to count active nodes")
|
|
expect(handle).to have_received(:close)
|
|
end
|
|
end
|
|
|
|
describe ".active_node_count_since_for_protocol (rescue path)" do
|
|
it "logs and returns nil when the database query raises" do
|
|
handle = instance_double(SQLite3::Database)
|
|
allow(handle).to receive(:close)
|
|
allow(federation_helpers).to receive(:open_database).and_return(handle)
|
|
allow(federation_helpers).to receive(:with_busy_retry).and_raise(ArgumentError, "bad value")
|
|
|
|
expect(federation_helpers.active_node_count_since_for_protocol(100, "meshcore")).to be_nil
|
|
expect(federation_helpers.warn_messages.last).to include("Failed to count active nodes for protocol")
|
|
expect(handle).to have_received(:close)
|
|
end
|
|
end
|
|
|
|
describe "federation counts honour the opt-out marker" do
|
|
around do |example|
|
|
Dir.mktmpdir("federation-optout-") do |dir|
|
|
db_path = File.join(dir, "mesh.db")
|
|
RSpec::Mocks.with_temporary_scope do
|
|
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
|
|
allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000)
|
|
db_helper = Object.new.extend(PotatoMesh::App::Database)
|
|
db_helper.init_db
|
|
db_helper.ensure_schema_upgrades
|
|
example.run
|
|
end
|
|
end
|
|
end
|
|
|
|
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
|
|
let(:now) { Time.now.to_i }
|
|
|
|
it "excludes opted-out nodes from active_node_count_since" do
|
|
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
|
db.execute(
|
|
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
|
|
"VALUES (?,?,?,?,?,?,?,?)",
|
|
["!visnode1", 0x10000001, "VN", "Visible", now, now, "CLIENT", "meshtastic"],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
|
|
"VALUES (?,?,?,?,?,?,?,?)",
|
|
["!hidnode1", 0x10000002, "HN", "Hidden #{marker}", now, now, "CLIENT", "meshtastic"],
|
|
)
|
|
db.close
|
|
|
|
expect(federation_helpers.active_node_count_since(now - 60)).to eq(1)
|
|
end
|
|
|
|
it "excludes opted-out nodes from active_node_count_since_for_protocol" do
|
|
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
|
db.execute(
|
|
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
|
|
"VALUES (?,?,?,?,?,?,?,?)",
|
|
["!mcvisible", 0x20000001, "MV", "MC Visible", now, now, "COMPANION", "meshcore"],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
|
|
"VALUES (?,?,?,?,?,?,?,?)",
|
|
["!mchidden", 0x20000002, "MH", "MC #{marker} Hidden", now, now, "COMPANION", "meshcore"],
|
|
)
|
|
db.close
|
|
|
|
expect(federation_helpers.active_node_count_since_for_protocol(now - 60, "meshcore")).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe ".federation_worker_pool" do
|
|
it "delegates to ensure_federation_worker_pool!" do
|
|
sentinel = Object.new
|
|
allow(federation_helpers).to receive(:ensure_federation_worker_pool!).and_return(sentinel)
|
|
|
|
expect(federation_helpers.federation_worker_pool).to equal(sentinel)
|
|
end
|
|
end
|
|
|
|
describe ".self_instance_domain" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
|
|
it "returns the sanitized INSTANCE_DOMAIN when present" do
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
|
|
|
|
expect(application_class.self_instance_domain).to eq("example.mesh")
|
|
end
|
|
|
|
it "logs and returns nil outside production when the domain is missing" do
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return(nil)
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN_SOURCE).and_return(:default)
|
|
allow(application_class).to receive(:production_environment?).and_return(false)
|
|
allow(application_class).to receive(:debug_log)
|
|
|
|
expect(application_class.self_instance_domain).to be_nil
|
|
expect(application_class).to have_received(:debug_log).with(
|
|
a_string_including("INSTANCE_DOMAIN unavailable"),
|
|
hash_including(context: "federation.instances"),
|
|
)
|
|
end
|
|
|
|
it "raises in production when the domain cannot be determined" do
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return(nil)
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN_SOURCE).and_return(:environment)
|
|
allow(application_class).to receive(:production_environment?).and_return(true)
|
|
|
|
expect { application_class.self_instance_domain }.to raise_error(RuntimeError, /INSTANCE_DOMAIN/)
|
|
end
|
|
end
|
|
|
|
describe ".self_instance_registration_decision" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
|
|
it "rejects domains that resolve to restricted IP addresses" do
|
|
allow(application_class).to receive(:app_constant).with(:INSTANCE_DOMAIN_SOURCE).and_return(:environment)
|
|
allow(application_class).to receive(:ip_from_domain).and_return(IPAddr.new("127.0.0.1"))
|
|
allow(application_class).to receive(:restricted_ip_address?).and_return(true)
|
|
|
|
allowed, reason = application_class.self_instance_registration_decision("loopback.mesh")
|
|
|
|
expect(allowed).to be(false)
|
|
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
|
|
end
|
|
end
|
|
|
|
describe ".ensure_self_instance_record! (skip branch)" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
|
|
before do
|
|
PotatoMesh::App::Federation.reset_self_registration_log_state!
|
|
end
|
|
|
|
it "logs without writing when registration is not allowed" do
|
|
attributes = { id: "self", domain: "self.mesh" }
|
|
allow(application_class).to receive(:self_instance_attributes).and_return(attributes)
|
|
allow(application_class).to receive(:sign_instance_attributes).and_return("sig")
|
|
allow(application_class).to receive(:self_instance_registration_decision).and_return([false, "blocked"])
|
|
allow(application_class).to receive(:open_database)
|
|
allow(application_class).to receive(:upsert_instance_record)
|
|
allow(application_class).to receive(:debug_log)
|
|
|
|
result_attrs, result_sig = application_class.ensure_self_instance_record!
|
|
|
|
expect(result_attrs).to eq(attributes)
|
|
expect(result_sig).to eq("sig")
|
|
expect(application_class).not_to have_received(:open_database)
|
|
expect(application_class).not_to have_received(:upsert_instance_record)
|
|
expect(application_class).to have_received(:debug_log).with(
|
|
"Skipped self instance registration",
|
|
hash_including(reason: "blocked"),
|
|
)
|
|
end
|
|
|
|
it "deduplicates the skip log when the decision does not change" do
|
|
attributes = { id: "self", domain: "self.mesh" }
|
|
allow(application_class).to receive(:self_instance_attributes).and_return(attributes)
|
|
allow(application_class).to receive(:sign_instance_attributes).and_return("sig")
|
|
allow(application_class).to receive(:self_instance_registration_decision).and_return([false, "blocked"])
|
|
allow(application_class).to receive(:debug_log)
|
|
|
|
3.times { application_class.ensure_self_instance_record! }
|
|
|
|
expect(application_class).to have_received(:debug_log).with(
|
|
"Skipped self instance registration",
|
|
hash_including(reason: "blocked"),
|
|
).once
|
|
end
|
|
|
|
it "re-emits the log when the decision flips between calls" do
|
|
attributes = { id: "self", domain: "self.mesh" }
|
|
allow(application_class).to receive(:self_instance_attributes).and_return(attributes)
|
|
allow(application_class).to receive(:sign_instance_attributes).and_return("sig")
|
|
allow(application_class).to receive(:open_database)
|
|
allow(application_class).to receive(:upsert_instance_record)
|
|
allow(application_class).to receive(:debug_log)
|
|
|
|
decisions = [
|
|
[false, "blocked"],
|
|
[false, "blocked"],
|
|
[true, nil],
|
|
[true, nil],
|
|
[false, "blocked"],
|
|
]
|
|
allow(application_class).to receive(:self_instance_registration_decision) do
|
|
decisions.shift
|
|
end
|
|
|
|
5.times { application_class.ensure_self_instance_record! }
|
|
|
|
expect(application_class).to have_received(:debug_log).with(
|
|
"Skipped self instance registration",
|
|
hash_including(reason: "blocked"),
|
|
).twice
|
|
expect(application_class).to have_received(:debug_log).with(
|
|
"Registered self instance record",
|
|
hash_including(domain: "self.mesh"),
|
|
).once
|
|
end
|
|
end
|
|
|
|
describe ".verify_instance_signature (failure path)" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
let(:attributes) { { id: "x", domain: "x.mesh" } }
|
|
|
|
it "returns false when the signature is not valid base64" do
|
|
result = application_class.verify_instance_signature(
|
|
attributes,
|
|
"not base64 !!!",
|
|
application_class::INSTANCE_PUBLIC_KEY_PEM,
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
end
|
|
|
|
it "returns false when the public key is malformed" do
|
|
result = application_class.verify_instance_signature(
|
|
attributes,
|
|
Base64.strict_encode64("anything"),
|
|
"-----BEGIN PUBLIC KEY-----\nnope\n-----END PUBLIC KEY-----\n",
|
|
)
|
|
|
|
expect(result).to be(false)
|
|
end
|
|
end
|
|
|
|
describe "instance signature v2 migration (FS1-FS4)" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
let(:keypair) { OpenSSL::PKey::RSA.new(2048) }
|
|
let(:pubkey_pem) { keypair.public_key.to_pem }
|
|
let(:attributes) do
|
|
{
|
|
id: "inst-1", domain: "a.mesh", pubkey: pubkey_pem, name: "A",
|
|
version: "0.7.0", channel: "#x", frequency: "915MHz",
|
|
latitude: 1.0, longitude: 2.0, last_update_time: 1_700_000_000,
|
|
is_private: false, contact_link: "#a:x", nodes_count: 7,
|
|
meshcore_nodes_count: 3, meshtastic_nodes_count: 4, reticulum_nodes_count: 0,
|
|
}
|
|
end
|
|
|
|
def sign_canonical(canonical)
|
|
Base64.strict_encode64(keypair.sign(OpenSSL::Digest::SHA256.new, canonical))
|
|
end
|
|
|
|
it "signs the v2 (snake_case) canonical with a signature_version marker and signed counts" do
|
|
parsed = JSON.parse(application_class.canonical_instance_payload(attributes))
|
|
expect(parsed).to include(
|
|
"public_key", "last_update", "is_private", "contact_link",
|
|
"nodes_count", "meshcore_nodes_count", "meshtastic_nodes_count",
|
|
"reticulum_nodes_count", "signature_version"
|
|
)
|
|
expect(parsed["signature_version"]).to eq(2)
|
|
expect(parsed).not_to have_key("publicKey")
|
|
expect(parsed).not_to have_key("lastUpdateTime")
|
|
expect(parsed).not_to have_key("isPrivate")
|
|
end
|
|
|
|
it "verifies a v2 signature" do
|
|
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
|
|
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
|
|
end
|
|
|
|
it "still verifies a legacy v1 (camelCase) signature — backward accept" do
|
|
sig = sign_canonical(application_class.canonical_instance_payload_v1(attributes))
|
|
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
|
|
end
|
|
|
|
it "rejects a tampered node count (counts are signed in v2)" do
|
|
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
|
|
tampered = attributes.merge(nodes_count: 999)
|
|
expect(application_class.verify_instance_signature(tampered, sig, pubkey_pem)).to be(false)
|
|
end
|
|
end
|
|
|
|
describe ".validate_well_known_document" do
|
|
let(:application_class) { PotatoMesh::Application }
|
|
let(:domain) { "remote.mesh" }
|
|
let(:keypair) { OpenSSL::PKey::RSA.new(2048) }
|
|
let(:pubkey_pem) { keypair.public_key.to_pem }
|
|
|
|
def signed_payload_for(domain_value, pubkey_value)
|
|
payload = JSON.generate({ "domain" => domain_value, "publicKey" => pubkey_value })
|
|
signature = keypair.sign(OpenSSL::Digest::SHA256.new, payload)
|
|
[Base64.strict_encode64(payload), Base64.strict_encode64(signature)]
|
|
end
|
|
|
|
it "rejects non-Hash documents" do
|
|
ok, reason = application_class.validate_well_known_document([], domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("document is not an object")
|
|
end
|
|
|
|
it "rejects unsupported signature algorithms" do
|
|
document = {
|
|
"publicKey" => pubkey_pem,
|
|
"domain" => domain,
|
|
"signatureAlgorithm" => "ed25519",
|
|
}
|
|
|
|
ok, reason = application_class.validate_well_known_document(document, domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("unsupported signature algorithm")
|
|
end
|
|
|
|
it "rejects documents whose signature does not verify against the public key" do
|
|
signed_b64, _ = signed_payload_for(domain, pubkey_pem)
|
|
bogus_signature = Base64.strict_encode64("not a valid signature")
|
|
document = {
|
|
"publicKey" => pubkey_pem,
|
|
"domain" => domain,
|
|
"signatureAlgorithm" => PotatoMesh::Config.instance_signature_algorithm,
|
|
"signedPayload" => signed_b64,
|
|
"signature" => bogus_signature,
|
|
}
|
|
|
|
ok, reason = application_class.validate_well_known_document(document, domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("invalid well-known signature")
|
|
end
|
|
|
|
it "rejects documents whose signed payload is not an object" do
|
|
payload = JSON.generate(["not", "an", "object"])
|
|
signature = keypair.sign(OpenSSL::Digest::SHA256.new, payload)
|
|
document = {
|
|
"publicKey" => pubkey_pem,
|
|
"domain" => domain,
|
|
"signatureAlgorithm" => PotatoMesh::Config.instance_signature_algorithm,
|
|
"signedPayload" => Base64.strict_encode64(payload),
|
|
"signature" => Base64.strict_encode64(signature),
|
|
}
|
|
|
|
ok, reason = application_class.validate_well_known_document(document, domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("signed payload is not an object")
|
|
end
|
|
|
|
it "wraps ArgumentError raised from base64 decoding" do
|
|
document = {
|
|
"publicKey" => pubkey_pem,
|
|
"domain" => domain,
|
|
"signatureAlgorithm" => PotatoMesh::Config.instance_signature_algorithm,
|
|
"signedPayload" => "not!base64!",
|
|
"signature" => "still!not!base64!",
|
|
}
|
|
|
|
ok, reason = application_class.validate_well_known_document(document, domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to be_a(String)
|
|
end
|
|
|
|
it "wraps JSON::ParserError when the signed payload is not valid JSON" do
|
|
payload = "not valid json"
|
|
signature = keypair.sign(OpenSSL::Digest::SHA256.new, payload)
|
|
document = {
|
|
"publicKey" => pubkey_pem,
|
|
"domain" => domain,
|
|
"signatureAlgorithm" => PotatoMesh::Config.instance_signature_algorithm,
|
|
"signedPayload" => Base64.strict_encode64(payload),
|
|
"signature" => Base64.strict_encode64(signature),
|
|
}
|
|
|
|
ok, reason = application_class.validate_well_known_document(document, domain, pubkey_pem)
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to include("signed payload JSON error")
|
|
end
|
|
end
|
|
|
|
describe ".validate_remote_nodes (rejection paths)" do
|
|
it "rejects payloads that are not arrays" do
|
|
ok, reason = federation_helpers.validate_remote_nodes("not an array")
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("node response is not an array")
|
|
end
|
|
|
|
it "rejects payloads with too few nodes" do
|
|
allow(PotatoMesh::Config).to receive(:remote_instance_min_node_count).and_return(5)
|
|
ok, reason = federation_helpers.validate_remote_nodes([{ "last_heard" => Time.now.to_i }])
|
|
|
|
expect(ok).to be(false)
|
|
expect(reason).to eq("insufficient nodes")
|
|
end
|
|
end
|
|
end
|