mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-03 12:02:41 +02:00
* web: further refine the federation page * web: address review comments * web: address review comments
779 lines
29 KiB
Ruby
779 lines
29 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
|
|
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 settings
|
|
@settings ||= Struct.new(
|
|
:federation_thread,
|
|
:initial_federation_thread,
|
|
:federation_worker_pool,
|
|
).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.shutdown_federation_worker_pool!
|
|
end
|
|
|
|
after do
|
|
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
|
|
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], "/api/nodes"]] = [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
|
|
|
|
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
|
|
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
|
|
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_instance_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)
|
|
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_instance_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_instance_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_instance_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("Net::HTTPConnection")
|
|
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_instance_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
|
|
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("Net::HTTPConnection") }
|
|
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
|
|
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)
|
|
|
|
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
|
|
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"))
|
|
|
|
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 "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
|
|
|
|
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))
|
|
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 "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 ".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
|
|
end
|
|
|
|
describe ".enqueue_federation_crawl" do
|
|
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
|
|
|
|
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 "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 "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
|
|
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
|
|
end
|
|
end
|