web: fix federation resolver issue with multi addresses (#743)

* web: fix federation resolver issue with multi addresses

* web: add tests

* web: address review comments
This commit is contained in:
l5y
2026-04-14 18:55:40 +02:00
committed by GitHub
parent c08b3f2c2d
commit 07c8e85caa
2 changed files with 512 additions and 61 deletions
+163 -13
View File
@@ -421,6 +421,13 @@ module PotatoMesh
db&.close
end
# Announce the local instance record to a remote federation peer,
# cycling through resolved IP addresses when transport-level failures
# occur.
#
# @param domain [String] remote peer hostname.
# @param payload_json [String] JSON-encoded announcement body.
# @return [Boolean] true when the announcement was accepted.
def announce_instance_to_domain(domain, payload_json)
return false unless domain && !domain.empty?
return false if federation_shutdown_requested?
@@ -431,14 +438,7 @@ module PotatoMesh
break false if federation_shutdown_requested?
begin
http = build_remote_http_client(uri)
response = Timeout.timeout(PotatoMesh::Config.remote_instance_request_timeout) do
http.start do |connection|
request = build_federation_http_request(Net::HTTP::Post, uri)
request.body = payload_json
connection.request(request)
end
end
response = perform_announce_request(uri, payload_json)
if response.is_a?(Net::HTTPSuccess)
debug_log(
"Published federation announcement",
@@ -492,6 +492,55 @@ module PotatoMesh
published
end
# Execute a POST announcement request against the supplied URI, cycling
# through resolved IP addresses on connection-level failures.
#
# @param uri [URI::Generic] target endpoint.
# @param payload_json [String] JSON-encoded announcement body.
# @return [Net::HTTPResponse] the HTTP response from the first reachable address.
# @raise [StandardError] when all addresses fail or a non-retryable error occurs.
def perform_announce_request(uri, payload_json)
remote_addresses = sort_addresses_for_connection(resolve_remote_ip_addresses(uri))
addresses = remote_addresses.empty? ? [nil] : remote_addresses
last_error = nil
addresses.each do |address|
break if federation_shutdown_requested?
begin
return perform_single_announce_request(uri, payload_json, ip_address: address&.to_s)
rescue StandardError => e
if connection_refused_or_unreachable?(e)
last_error = e
else
raise
end
end
end
raise(last_error || StandardError.new("all resolved addresses failed"))
end
# Execute a single POST announcement request, optionally pinning the
# connection to a specific IP address.
#
# @param uri [URI::Generic] target endpoint.
# @param payload_json [String] JSON-encoded announcement body.
# @param ip_address [String, nil] resolved IP address to pin the
# connection to, or +nil+ to let {build_remote_http_client} resolve.
# @return [Net::HTTPResponse] the HTTP response.
# @raise [StandardError] when the request fails.
def perform_single_announce_request(uri, payload_json, ip_address: nil)
http = build_remote_http_client(uri, ip_address: ip_address)
Timeout.timeout(PotatoMesh::Config.remote_instance_request_timeout) do
http.start do |connection|
request = build_federation_http_request(Net::HTTP::Post, uri)
request.body = payload_json
connection.request(request)
end
end
end
# Determine whether an HTTPS announcement failure should fall back to HTTP.
#
# @param error [StandardError] failure raised while attempting HTTPS.
@@ -507,6 +556,34 @@ module PotatoMesh
false
end
# Determine whether an error indicates a transport-level connection
# failure that may succeed on an alternative resolved address.
#
# Connection refusals, host/network unreachable errors, and TCP open
# timeouts signal that the selected IP address cannot be reached but
# do not rule out alternative addresses for the same hostname.
#
# @param error [StandardError] failure raised during the connection attempt.
# @return [Boolean] true when a retry with a different address is warranted.
def connection_refused_or_unreachable?(error)
retryable_classes = [
Errno::ECONNREFUSED,
Errno::EHOSTUNREACH,
Errno::ENETUNREACH,
Errno::ECONNRESET,
Errno::ETIMEDOUT,
Net::OpenTimeout,
]
current = error
while current
return true if retryable_classes.any? { |klass| current.is_a?(klass) }
current = current.respond_to?(:cause) ? current.cause : nil
end
false
end
def announce_instance_to_all_domains
return unless federation_enabled?
return if federation_shutdown_requested?
@@ -705,10 +782,57 @@ module PotatoMesh
[]
end
# Execute a GET request against the supplied federation URI, cycling
# through resolved IP addresses when a transport-level connection
# failure occurs.
#
# DNS resolution is performed once and the resulting addresses are
# sorted with IPv4 first via {sort_addresses_for_connection}. Each
# address is attempted sequentially; when a connection-level error
# (refused, unreachable, timeout) is raised the next address is tried.
# Non-connection errors (SSL failures, HTTP-level errors) are raised
# immediately without trying further addresses.
#
# @param uri [URI::Generic] target endpoint to request.
# @return [String] raw HTTP response body on success.
# @raise [InstanceFetchError] when all addresses are exhausted or a
# non-retryable error occurs.
def perform_instance_http_request(uri)
raise InstanceFetchError, "federation shutdown requested" if federation_shutdown_requested?
http = build_remote_http_client(uri)
remote_addresses = sort_addresses_for_connection(resolve_remote_ip_addresses(uri))
addresses = remote_addresses.empty? ? [nil] : remote_addresses
last_error = nil
addresses.each do |address|
break if federation_shutdown_requested?
begin
return perform_single_http_request(uri, ip_address: address&.to_s)
rescue InstanceFetchError => e
if connection_refused_or_unreachable?(e)
last_error = e
else
raise
end
end
end
raise last_error || InstanceFetchError.new("all resolved addresses failed")
rescue ArgumentError => e
raise_instance_fetch_error(e)
end
# Execute a single HTTP GET request against the supplied URI, optionally
# pinning the connection to a specific IP address.
#
# @param uri [URI::Generic] target endpoint.
# @param ip_address [String, nil] resolved IP address to pin the
# connection to, or +nil+ to let {build_remote_http_client} resolve.
# @return [String] raw HTTP response body.
# @raise [InstanceFetchError] when the request fails.
def perform_single_http_request(uri, ip_address: nil)
http = build_remote_http_client(uri, ip_address: ip_address)
Timeout.timeout(PotatoMesh::Config.remote_instance_request_timeout) do
http.start do |connection|
request = build_federation_http_request(Net::HTTP::Get, uri)
@@ -1246,15 +1370,41 @@ module PotatoMesh
unrestricted_addresses
end
# Sort resolved addresses so that IPv4 precedes IPv6.
#
# Federation peers with dual-stack DNS may publish addresses where one
# family is unreachable. Placing IPv4 entries first mirrors the
# preference used by {discover_local_ip_address} and improves the
# likelihood that the first connection attempt succeeds.
#
# @param addresses [Array<IPAddr>] resolved IP address list.
# @return [Array<IPAddr>] addresses sorted with IPv4 entries before IPv6.
def sort_addresses_for_connection(addresses)
return addresses if addresses.nil? || addresses.length <= 1
v4, v6 = addresses.partition { |ip| !ip.ipv6? }
v4 + v6
end
# Build an HTTP client configured for communication with a remote instance.
#
# When +ip_address+ is supplied the client is pinned to that specific
# address, bypassing DNS resolution. Callers that iterate over
# multiple resolved addresses should pass each candidate in turn.
#
# @param uri [URI::Generic] target URI describing the remote endpoint.
# @param ip_address [String, nil] explicit IP address to connect to,
# or +nil+ to resolve via DNS and use the first result.
# @return [Net::HTTP] HTTP client ready to execute the request.
def build_remote_http_client(uri)
remote_addresses = resolve_remote_ip_addresses(uri)
def build_remote_http_client(uri, ip_address: nil)
http = Net::HTTP.new(uri.host, uri.port)
if http.respond_to?(:ipaddr=) && remote_addresses.any?
http.ipaddr = remote_addresses.first.to_s
if ip_address
http.ipaddr = ip_address if http.respond_to?(:ipaddr=)
else
remote_addresses = resolve_remote_ip_addresses(uri)
if http.respond_to?(:ipaddr=) && remote_addresses.any?
http.ipaddr = remote_addresses.first.to_s
end
end
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.read_timeout = PotatoMesh::Config.remote_instance_read_timeout
+349 -48
View File
@@ -239,6 +239,125 @@ RSpec.describe PotatoMesh::App::Federation do
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
@@ -702,12 +821,12 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe ".perform_instance_http_request" do
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).with(uri).and_return(http_client)
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
@@ -723,7 +842,7 @@ RSpec.describe PotatoMesh::App::Federation do
allow(http_client).to receive(:start).and_raise(RemoteTcpFailure.new)
expect do
federation_helpers.send(:perform_instance_http_request, uri)
federation_helpers.send(:perform_single_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "RemoteTcpFailure")
end
@@ -731,7 +850,7 @@ RSpec.describe PotatoMesh::App::Federation do
allow(http_client).to receive(:start).and_raise(OpenSSL::SSL::SSLError.new("handshake failed"))
expect do
federation_helpers.send(:perform_instance_http_request, uri)
federation_helpers.send(:perform_single_http_request, uri)
end.to raise_error(
PotatoMesh::App::InstanceFetchError,
"OpenSSL::SSL::SSLError: handshake failed",
@@ -742,19 +861,10 @@ RSpec.describe PotatoMesh::App::Federation do
allow(http_client).to receive(:start).and_raise(Net::ReadTimeout.new)
expect do
federation_helpers.send(:perform_instance_http_request, uri)
federation_helpers.send(:perform_single_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
end
it "wraps restricted address resolution failures" do
allow(federation_helpers).to receive(:build_remote_http_client).and_call_original
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 "applies federation headers to instance fetch requests" do
connection = instance_double(HTTP_CONNECTION_DOUBLE)
success_response = Net::HTTPOK.new("1.1", "200", "OK")
@@ -770,7 +880,7 @@ RSpec.describe PotatoMesh::App::Federation do
success_response
end
result = federation_helpers.send(:perform_instance_http_request, uri)
result = federation_helpers.send(:perform_single_http_request, uri)
expect(result).to eq("{}")
expect(captured_request).not_to be_nil
@@ -790,12 +900,144 @@ RSpec.describe PotatoMesh::App::Federation do
allow(connection).to receive(:request).and_return(failure_response)
expect do
federation_helpers.send(:perform_instance_http_request, uri)
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
end
describe ".federation_sleep_with_shutdown" do
@@ -823,11 +1065,84 @@ RSpec.describe PotatoMesh::App::Federation do
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(:http_connection) { instance_double(HTTP_CONNECTION_DOUBLE) }
let(:success_response) { Net::HTTPOK.new("1.1", "200", "OK") }
before do
@@ -835,15 +1150,12 @@ RSpec.describe PotatoMesh::App::Federation do
end
it "retries over HTTP when HTTPS connections are refused" do
https_client = instance_double(Net::HTTP)
http_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client)
allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused"))
allow(http_connection).to receive(:request).and_return(success_response)
allow(http_client).to receive(:start).and_yield(http_connection).and_return(success_response)
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)
@@ -853,14 +1165,12 @@ RSpec.describe PotatoMesh::App::Federation do
end
it "logs a warning when HTTPS refusal persists after HTTP fallback" do
https_client = instance_double(Net::HTTP)
http_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client)
allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused"))
allow(http_client).to receive(:start).and_raise(SocketError.new("dns failure"))
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)
@@ -871,24 +1181,15 @@ RSpec.describe PotatoMesh::App::Federation do
).to eq(2)
end
it "applies federation headers to announcement requests" do
https_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
captured_request = nil
allow(https_client).to receive(:start).and_yield(http_connection).and_return(success_response)
allow(http_connection).to receive(:request) do |request|
captured_request = request
success_response
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(captured_request).not_to be_nil
expect(captured_request["Content-Type"]).to eq("application/json")
expect(captured_request["Accept"]).to eq("application/json")
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
expect(federation_helpers.debug_messages).to include("Published federation announcement")
end
end