# Copyright (C) 2025 l5yth # # 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 "sqlite3" require "json" require "time" require "base64" require "uri" require "socket" RSpec.describe "Potato Mesh Sinatra app" do let(:app) { Sinatra::Application } let(:application_class) { PotatoMesh::Application } describe "configuration" do it "sets the default HTTP port to the baked-in value" do expect(app.settings.port).to eq(PotatoMesh::Application::DEFAULT_PORT) end end describe ".resolve_port" do around do |example| original_port = ENV["PORT"] begin example.run ensure if original_port ENV["PORT"] = original_port else ENV.delete("PORT") end end end it "returns the baked-in default port when PORT is not provided" do ENV.delete("PORT") expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) end it "honours a valid PORT override" do ENV["PORT"] = "51515" expect(application_class.resolve_port).to eq(51_515) end it "falls back to the default for invalid PORT values" do ENV["PORT"] = "abc" expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) ENV["PORT"] = "70000" expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) ENV["PORT"] = "0" expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) end end # Return the absolute filesystem path to the requested fixture. # # @param name [String] fixture filename relative to the tests directory. # @return [String] absolute path to the fixture file. def fixture_path(name) File.expand_path("../../tests/#{name}", __dir__) end # Execute the provided block with a configured SQLite connection. # # @param readonly [Boolean] whether to open the database in read-only mode. # @yieldparam db [SQLite3::Database] open database handle. # @return [void] def with_db(readonly: false) db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly) db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms db.execute("PRAGMA foreign_keys = ON") yield db ensure db&.close end # Remove all rows from the tables used by the application under test. # # @return [void] def clear_database with_db do |db| db.execute("DELETE FROM instances") db.execute("DELETE FROM neighbors") db.execute("DELETE FROM messages") db.execute("DELETE FROM nodes") db.execute("DELETE FROM positions") db.execute("DELETE FROM telemetry") end ensure_self_instance_record! end # Retrieve the number of rows stored in the instances table. # # @return [Integer] count of stored instance records. def instance_count with_db(readonly: true) do |db| db.get_first_value("SELECT COUNT(*) FROM instances").to_i end end # Build a hash excluding entries whose values are nil. # # @param hash [Hash] collection filtered for nil values. # @return [Hash] hash containing only keys with non-nil values. def reject_nil_values(hash) hash.reject { |_, value| value.nil? } end # Construct a request payload mirroring the structure produced by the daemon. # # @param node [Hash] node attributes from the fixture dataset. # @return [Hash] payload formatted for the API. def build_node_payload(node) payload = { "user" => reject_nil_values( "shortName" => node["short_name"], "longName" => node["long_name"], "hwModel" => node["hw_model"], "role" => node["role"], ), "hwModel" => node["hw_model"], "lastHeard" => node["last_heard"], "snr" => node["snr"], } metrics = reject_nil_values( "batteryLevel" => node["battery_level"], "voltage" => node["voltage"], "channelUtilization" => node["channel_utilization"], "airUtilTx" => node["air_util_tx"], "uptimeSeconds" => node["uptime_seconds"], ) payload["deviceMetrics"] = metrics unless metrics.empty? position = reject_nil_values( "time" => node["position_time"], "latitude" => node["latitude"], "longitude" => node["longitude"], "altitude" => node["altitude"], "locationSource" => node["location_source"], "precisionBits" => node["precision_bits"], ) payload["position"] = position unless position.empty? payload["lora_freq"] = node["lora_freq"] if node.key?("lora_freq") payload["modem_preset"] = node["modem_preset"] if node.key?("modem_preset") payload end # Determine the expected last heard timestamp for a node fixture. # # @param node [Hash] node attributes from the fixture dataset. # @return [Integer, nil] canonical last heard timestamp. def expected_last_heard(node) [node["last_heard"], node["position_time"]].compact.max end # Assemble the expected row persisted in the nodes table. # # @param node [Hash] node attributes from the fixture dataset. # @return [Hash] expected database row for assertions. def expected_node_row(node) final_last = expected_last_heard(node) { "node_id" => node["node_id"], "short_name" => node["short_name"], "long_name" => node["long_name"], "hw_model" => node["hw_model"], "role" => node["role"] || "CLIENT", "snr" => node["snr"], "battery_level" => node["battery_level"], "voltage" => node["voltage"], "last_heard" => final_last, "first_heard" => final_last, "uptime_seconds" => node["uptime_seconds"], "channel_utilization" => node["channel_utilization"], "air_util_tx" => node["air_util_tx"], "position_time" => node["position_time"], "location_source" => node["location_source"], "precision_bits" => node["precision_bits"], "latitude" => node["latitude"], "longitude" => node["longitude"], "altitude" => node["altitude"], "lora_freq" => node["lora_freq"], "modem_preset" => node["modem_preset"], } end # Assert equality while supporting tolerance for floating point comparisons. # # @param actual [Object] observed value. # @param expected [Object] expected value. # @param tolerance [Float] acceptable delta for floating point values. # @return [void] def expect_same_value(actual, expected, tolerance: 1e-6) if expected.nil? expect(actual).to be_nil elsif expected.is_a?(Float) expect(actual).to be_within(tolerance).of(expected) else expect(actual).to eq(expected) end end # Import all nodes defined in the fixture file via the HTTP API. # # @return [void] def import_nodes_fixture nodes_fixture.each do |node| payload = { node["node_id"] => build_node_payload(node) } post "/api/nodes", payload.to_json, auth_headers expect(last_response).to be_ok expect(JSON.parse(last_response.body)).to eq("status" => "ok") end end # Import all messages defined in the fixture file via the HTTP API. # # @return [void] def import_messages_fixture messages_fixture.each do |message| payload = message.reject { |key, _| key == "node" } post "/api/messages", payload.to_json, auth_headers expect(last_response).to be_ok expect(JSON.parse(last_response.body)).to eq("status" => "ok") end end let(:api_token) { "spec-token" } let(:auth_headers) do { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{api_token}", } end let(:nodes_fixture) { JSON.parse(File.read(fixture_path("nodes.json"))) } let(:messages_fixture) { JSON.parse(File.read(fixture_path("messages.json"))) } let(:telemetry_fixture) { JSON.parse(File.read(fixture_path("telemetry.json"))) } let(:reference_time) do latest = nodes_fixture.map { |node| node["last_heard"] }.compact.max Time.at((latest || Time.now.to_i) + 1000) end describe "federation announcers" do class DummyThread attr_accessor :name, :report_on_exception, :block def alive? false end end let(:dummy_thread) { DummyThread.new } before do app.set(:initial_federation_thread, nil) app.set(:federation_thread, nil) end it "stores and clears the initial federation thread" do delay = 3 allow(PotatoMesh::Config).to receive(:initial_federation_delay_seconds).and_return(delay) expect(Kernel).to receive(:sleep).with(delay) expect(app).to receive(:announce_instance_to_all_domains) allow(Thread).to receive(:new) do |&block| dummy_thread.block = block dummy_thread end result = app.start_initial_federation_announcement! expect(result).to be(dummy_thread) expect(app.settings.initial_federation_thread).to be(dummy_thread) expect(dummy_thread.block).not_to be_nil expect { dummy_thread.block.call }.to change { app.settings.initial_federation_thread }.from(dummy_thread).to(nil) end it "stores the recurring federation announcer thread" do allow(Thread).to receive(:new) do |&block| dummy_thread.block = block dummy_thread end result = app.start_federation_announcer! expect(result).to be(dummy_thread) expect(app.settings.federation_thread).to be(dummy_thread) end context "when federation is disabled" do around do |example| original = ENV["FEDERATION"] begin ENV["FEDERATION"] = "0" example.run ensure if original.nil? ENV.delete("FEDERATION") else ENV["FEDERATION"] = original end end end it "does not start the initial announcement thread" do expect(Thread).not_to receive(:new) result = app.start_initial_federation_announcement! expect(result).to be_nil expect(app.settings.respond_to?(:initial_federation_thread) ? app.settings.initial_federation_thread : nil).to be_nil end it "does not start the recurring announcer thread" do expect(Thread).not_to receive(:new) result = app.start_federation_announcer! expect(result).to be_nil expect(app.settings.federation_thread).to be_nil end end end before do @original_token = ENV["API_TOKEN"] @original_private = ENV["PRIVATE"] ENV["API_TOKEN"] = api_token ENV.delete("PRIVATE") allow(Time).to receive(:now).and_return(reference_time) clear_database end after do ENV["API_TOKEN"] = @original_token if @original_private.nil? ENV.delete("PRIVATE") else ENV["PRIVATE"] = @original_private end end describe "helper utilities" do describe "#fetch_config_string" do around do |example| key = "SPEC_FETCH" original = ENV[key] begin ENV.delete(key) example.run ensure if original.nil? ENV.delete(key) else ENV[key] = original end end end it "returns the default when the environment variable is missing" do expect(fetch_config_string("SPEC_FETCH", "fallback")).to eq("fallback") end it "strips whitespace and rejects blank overrides" do ENV["SPEC_FETCH"] = " \t " expect(fetch_config_string("SPEC_FETCH", "fallback")).to eq("fallback") ENV["SPEC_FETCH"] = " override " expect(fetch_config_string("SPEC_FETCH", "fallback")).to eq("override") end end describe "#determine_instance_domain" do around do |example| original = ENV["INSTANCE_DOMAIN"] begin ENV.delete("INSTANCE_DOMAIN") example.run ensure if original.nil? ENV.delete("INSTANCE_DOMAIN") else ENV["INSTANCE_DOMAIN"] = original end end end it "uses the environment override when provided" do ENV["INSTANCE_DOMAIN"] = " example.org " domain, source = determine_instance_domain expect(domain).to eq("example.org") expect(source).to eq(:environment) end it "normalises scheme-based environment overrides" do ENV["INSTANCE_DOMAIN"] = " https://Example.Org " domain, source = determine_instance_domain expect(domain).to eq("example.org") expect(source).to eq(:environment) end it "allows IP addresses configured via the environment" do ENV["INSTANCE_DOMAIN"] = "http://127.0.0.1" domain, source = determine_instance_domain expect(domain).to eq("127.0.0.1") expect(source).to eq(:environment) end it "rejects instance domains containing path components" do ENV["INSTANCE_DOMAIN"] = "https://example.org/app" expect { determine_instance_domain }.to raise_error( RuntimeError, /must not include a path component/, ) end it "falls back to reverse DNS when available" do address = Addrinfo.ip("203.0.113.10") allow(Socket).to receive(:ip_address_list).and_return([address]) allow(Resolv).to receive(:getname).with("203.0.113.10").and_return("chara.htznr.fault.dev") domain, source = determine_instance_domain expect(domain).to eq("chara.htznr.fault.dev") expect(source).to eq(:reverse_dns) end it "falls back to a public IP address when reverse DNS is unavailable" do public_address = Addrinfo.ip("203.0.113.20") allow(Socket).to receive(:ip_address_list).and_return([public_address]) allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) domain, source = determine_instance_domain expect(domain).to eq("203.0.113.20") expect(source).to eq(:public_ip) end it "falls back to a protected IP address when only private networks exist" do private_address = Addrinfo.ip("10.0.0.5") allow(Socket).to receive(:ip_address_list).and_return([private_address]) allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) domain, source = determine_instance_domain expect(domain).to eq("10.0.0.5") expect(source).to eq(:protected_ip) end it "falls back to a local IP address when no other sources are available" do loopback_address = Addrinfo.ip("127.0.0.1") allow(Socket).to receive(:ip_address_list).and_return([loopback_address]) allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) domain, source = determine_instance_domain expect(domain).to eq("127.0.0.1") expect(source).to eq(:local_ip) end end describe ".locate_git_repo_root" do it "returns nil when a git directory cannot be found" do nested_dir = Dir.mktmpdir("potato-mesh-no-git-") begin deep_dir = File.join(nested_dir, "a", "b", "c") FileUtils.mkdir_p(deep_dir) result = application_class.send(:locate_git_repo_root, deep_dir) expect(result).to be_nil ensure FileUtils.remove_entry(nested_dir) end end it "locates a git directory" do nested_dir = Dir.mktmpdir("potato-mesh-with-git-") begin repo_root = File.join(nested_dir, "repo") FileUtils.mkdir_p(File.join(repo_root, ".git")) deep_dir = File.join(repo_root, "lib", "potato") FileUtils.mkdir_p(deep_dir) result = application_class.send(:locate_git_repo_root, deep_dir) expect(result).to eq(repo_root) ensure FileUtils.remove_entry(nested_dir) end end it "recognises git worktree files" do nested_dir = Dir.mktmpdir("potato-mesh-worktree-") begin repo_root = File.join(nested_dir, "worktree") FileUtils.mkdir_p(repo_root) File.write(File.join(repo_root, ".git"), "gitdir: /tmp/worktree") deep_dir = File.join(repo_root, "app", "lib") FileUtils.mkdir_p(deep_dir) result = application_class.send(:locate_git_repo_root, deep_dir) expect(result).to eq(repo_root) ensure FileUtils.remove_entry(nested_dir) end end end describe "#determine_app_version" do let(:repo_root) { File.expand_path("..", __dir__) } it "returns the fallback when the git directory is missing" do allow(application_class).to receive(:locate_git_repo_root).and_return(nil) expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback) end it "returns the fallback when git describe fails" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) status = instance_double(Process::Status, success?: false) allow(Open3).to receive(:capture2).and_return(["ignored", status]) expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback) end it "returns the fallback when git describe output is empty" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) status = instance_double(Process::Status, success?: true) allow(Open3).to receive(:capture2).and_return(["\n", status]) expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback) end it "returns the original describe output when the format is unexpected" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) status = instance_double(Process::Status, success?: true) allow(Open3).to receive(:capture2).and_return(["weird-output", status]) expect(application_class.determine_app_version).to eq("weird-output") end it "normalises the version when no commits are ahead of the tag" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) status = instance_double(Process::Status, success?: true) allow(Open3).to receive(:capture2).and_return(["v1.2.3-0-gabcdef1", status]) expect(application_class.determine_app_version).to eq("v1.2.3") end it "includes commit metadata when ahead of the tag" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) status = instance_double(Process::Status, success?: true) allow(Open3).to receive(:capture2).and_return(["v1.2.3-5-gabcdef1", status]) expect(application_class.determine_app_version).to eq("v1.2.3+5-abcdef1") end it "returns the fallback when git describe raises an error" do allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root) allow(Open3).to receive(:capture2).and_raise(StandardError, "boom") expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback) end end describe "string coercion helpers" do it "normalises strings and nil values" do expect(sanitized_string(" spaced ")).to eq("spaced") expect(sanitized_string(nil)).to eq("") end it "returns nil for blank contact links" do allow(PotatoMesh::Config).to receive(:contact_link).and_return(" \t ") expect(sanitized_contact_link).to be_nil end it "coerces string_or_nil inputs" do expect(string_or_nil(" hello \n")).to eq("hello") expect(string_or_nil(" ")).to be_nil expect(string_or_nil(123)).to eq("123") end end describe "#coerce_integer" do it "coerces integers and floats" do expect(coerce_integer(5)).to eq(5) expect(coerce_integer(7.9)).to eq(7) end it "coerces numeric strings" do expect(coerce_integer(" 42 ")).to eq(42) expect(coerce_integer("0x1a")).to eq(26) expect(coerce_integer("12.8")).to eq(12) end it "returns nil for invalid values" do expect(coerce_integer("not-a-number")).to be_nil expect(coerce_integer(Float::INFINITY)).to be_nil end end describe "#coerce_float" do it "coerces numeric types" do expect(coerce_float(5)).to eq(5.0) expect(coerce_float(3.2)).to eq(3.2) end it "coerces numeric strings" do expect(coerce_float(" 8.5 ")).to eq(8.5) end it "returns nil for invalid inputs" do expect(coerce_float("bad")).to be_nil expect(coerce_float(Float::INFINITY)).to be_nil end end describe "JSON normalisation helpers" do it "normalises nested hashes" do input = { foo: { bar: 1, baz: [1, { qux: 2 }] } } result = normalize_json_value(input) expect(result).to eq("foo" => { "bar" => 1, "baz" => [1, { "qux" => 2 }] }) end it "parses JSON strings into hashes" do json = '{"foo": {"bar": 1}}' expect(normalize_json_object(json)).to eq("foo" => { "bar" => 1 }) end it "returns nil for invalid JSON objects" do expect(normalize_json_object("not json")).to be_nil expect(normalize_json_object(123)).to be_nil end end describe "distance helpers" do it "formats integers without trailing decimals" do expect(formatted_distance_km(120.0)).to eq("120") expect(formatted_distance_km(12.34)).to eq("12.3") end it "returns nil when the maximum distance is invalid" do allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(-5) expect(sanitized_max_distance_km).to be_nil allow(PotatoMesh::Config).to receive(:max_distance_km).and_return("string") expect(sanitized_max_distance_km).to be_nil allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(15.5) expect(sanitized_max_distance_km).to eq(15.5) end end describe "#secure_token_match?" do it "performs constant-time comparison for matching strings" do expect(secure_token_match?("abc", "abc")).to be(true) end it "returns false when inputs differ" do expect(secure_token_match?("abc", "xyz")).to be(false) expect(secure_token_match?("abc", nil)).to be(false) end it "handles secure compare errors" do stub_const("Rack::Utils::SecurityError", Class.new(StandardError)) allow(Rack::Utils).to receive(:secure_compare).and_raise(Rack::Utils::SecurityError.new("boom")) expect(secure_token_match?("abc", "abc")).to be(false) end end describe "#with_busy_retry" do it "raises once the retry budget is exhausted" do attempts = 0 expect do with_busy_retry(max_retries: 2, base_delay: 0.0) do attempts += 1 raise SQLite3::BusyException if attempts <= 3 end end.to raise_error(SQLite3::BusyException) expect(attempts).to eq(3) end end describe "#resolve_node_num" do it "reads numeric aliases from payloads" do expect(resolve_node_num(nil, "num" => 42)).to eq(42) expect(resolve_node_num(nil, "num" => 7.2)).to eq(7) expect(resolve_node_num(nil, "num" => " 123 ")).to eq(123) expect(resolve_node_num("!feedcafe", "num" => "feedcafe")).to eq(0xfeedcafe) end it "infers the numeric alias from the canonical identifier" do expect(resolve_node_num("!00ff00aa", {})).to eq(0x00ff00aa) end it "returns nil for invalid identifiers" do expect(resolve_node_num("!nothex", {})).to be_nil expect(resolve_node_num(nil, "num" => "")).to be_nil expect(resolve_node_num("", {})).to be_nil end end describe "#canonical_node_parts" do it "parses integers, strings, and fallbacks" do parts = canonical_node_parts(123, nil) expect(parts).to eq(["!0000007b", 123, "007B"]) parts = canonical_node_parts("!feedcafe", nil) expect(parts).to eq(["!feedcafe", 0xfeedcafe, "CAFE"]) parts = canonical_node_parts("0x10", nil) expect(parts).to eq(["!00000010", 16, "0010"]) parts = canonical_node_parts(nil, 31) expect(parts).to eq(["!0000001f", 31, "001F"]) end it "rejects invalid references" do expect(canonical_node_parts("", nil)).to be_nil expect(canonical_node_parts("not-valid", nil)).to be_nil expect(canonical_node_parts(-5, nil)).to be_nil expect(canonical_node_parts(Object.new, nil)).to be_nil end end describe "#ensure_unknown_node" do it "does not create duplicate placeholder nodes" do node_id = "!dupe0001" with_db do |db| db.execute("INSERT INTO nodes(node_id) VALUES (?)", [node_id]) expect(ensure_unknown_node(db, node_id, nil, heard_time: reference_time.to_i)).to be_falsey end end end describe "#touch_node_last_seen" do it "updates nodes using fallback numeric identifiers" do node_id = "!12345678" node_num = 0x1234_5678 rx_time = reference_time.to_i - 30 with_db do |db| db.execute( "INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES (?,?,?,?)", [node_id, node_num, rx_time - 120, rx_time - 180], ) updated = touch_node_last_seen(db, nil, node_num, rx_time: rx_time, source: :spec) expect(updated).to be_truthy end with_db(readonly: true) do |db| db.results_as_hash = true row = db.get_first_row( "SELECT last_heard, first_heard FROM nodes WHERE node_id = ?", [node_id], ) expect(row["last_heard"]).to eq(rx_time) expect(row["first_heard"]).to eq(rx_time - 180) end end it "returns nil when the timestamp cannot be coerced" do with_db do |db| expect(touch_node_last_seen(db, "!unknown", nil, rx_time: " ")).to be_nil end end end describe "#normalize_node_id" do it "resolves numeric aliases to canonical identifiers" do node_id = "!alias000" with_db do |db| db.execute( "INSERT INTO nodes(node_id, num) VALUES (?, ?)", [node_id, 321], ) end with_db(readonly: true) do |db| expect(normalize_node_id(db, "321")).to eq(node_id) expect(normalize_node_id(db, "!missing")).to be_nil expect(normalize_node_id(db, nil)).to be_nil end end end describe ".self_instance_registration_decision" do let(:domain) { "spec.mesh.test" } it "rejects registration when the domain source is not the environment" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do allowed, reason = application_class.self_instance_registration_decision(domain) expect(allowed).to be(false) expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns") end end it "rejects registration when the domain is invalid" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do allowed, reason = application_class.self_instance_registration_decision(nil) expect(allowed).to be(false) expect(reason).to eq("INSTANCE_DOMAIN missing or invalid") end end it "rejects registration when the domain resolves to a restricted IP" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do allowed, reason = application_class.self_instance_registration_decision("127.0.0.1") expect(allowed).to be(false) expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP") end end it "accepts registration when configuration is valid" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do allowed, reason = application_class.self_instance_registration_decision(domain) expect(allowed).to be(true) expect(reason).to be_nil end end end describe ".ensure_self_instance_record!" do it "persists the self instance when registration is allowed" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh") do with_db do |db| db.execute("DELETE FROM instances") end application_class.ensure_self_instance_record! expect(instance_count).to eq(1) end end end it "skips persistence when registration is not allowed" do stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do with_db do |db| db.execute("DELETE FROM instances") end application_class.ensure_self_instance_record! expect(instance_count).to eq(0) end end end end describe ".federation_target_domains" do it "prioritises seed domains before database records" do with_db do |db| db.execute( "INSERT INTO instances (id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "remote-id", "Remote.Mesh", "pubkey", "Remote", "1.0.0", nil, nil, nil, nil, Time.now.to_i, 0, "signature", ], ) end targets = application_class.federation_target_domains("self.mesh") expect(targets.first).to eq("potatomesh.net") expect(targets).to include("remote.mesh") expect(targets).not_to include("self.mesh") end it "falls back to seeds when the database is unavailable" do allow(application_class).to receive(:open_database).and_raise(SQLite3::Exception.new("boom")) targets = application_class.federation_target_domains("self.mesh") expect(targets).to eq(["potatomesh.net"]) end it "ignores remote instances that have not updated within a week" do with_db do |db| db.execute("DELETE FROM instances") stale_time = (Time.now.to_i - PotatoMesh::Config.week_seconds - 60) db.execute( "INSERT INTO instances (id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "stale-id", "stale.mesh", "pubkey", "Stale", "1.0.0", nil, nil, nil, nil, stale_time, 0, "signature", ], ) end targets = application_class.federation_target_domains("self.mesh") expect(targets).to eq(["potatomesh.net"]) end end describe ".latest_node_update_timestamp" do it "returns the maximum last_heard value" do with_db do |db| db.execute("DELETE FROM nodes") db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-a", 100]) db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-b", 200]) end expect(application_class.latest_node_update_timestamp).to eq(200) end it "returns nil when no nodes contain last_heard values" do with_db do |db| db.execute("DELETE FROM nodes") end expect(application_class.latest_node_update_timestamp).to be_nil end end describe ".build_well_known_document" do it "signs the payload and normalises the domain" do with_db do |db| db.execute("DELETE FROM nodes") db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-z", 321]) end stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET") do json_output, signature = application_class.build_well_known_document document = JSON.parse(json_output) expect(document["domain"]).to eq("example.net") expect(document["lastUpdate"]).to eq(321) expect(document["signatureAlgorithm"]).to eq("rsa-sha256") expect(signature).to be_a(String) expect(signature).not_to be_empty end end end describe ".upsert_instance_record" do it "rejects restricted domains" do attributes = { id: "restricted", domain: "127.0.0.1", pubkey: application_class::INSTANCE_PUBLIC_KEY_PEM, name: nil, version: nil, channel: nil, frequency: nil, latitude: nil, longitude: nil, last_update_time: Time.now.to_i, is_private: false, } expect do with_db do |db| application_class.upsert_instance_record(db, attributes, "sig") end end.to raise_error(ArgumentError, "restricted domain") end end describe "logging configuration" do before do Sinatra::Application.apply_logger_level! end after do Sinatra::Application.apply_logger_level! end it "defaults to WARN when debug logging is disabled" do expect(Sinatra::Application.settings.logger.level).to eq(Logger::WARN) end it "switches to DEBUG when debug logging is enabled" do allow(PotatoMesh::Config).to receive(:debug?).and_return(true) Sinatra::Application.apply_logger_level! expect(Sinatra::Application.settings.logger.level).to eq(Logger::DEBUG) end end describe "GET /favicon.ico" do it "serves the bundled favicon when available" do get "/favicon.ico" expect(last_response).to be_ok expect(last_response.headers["Content-Type"]).to eq("image/vnd.microsoft.icon") end it "falls back to the SVG logo when the favicon is missing" do ico_path = File.join(Sinatra::Application.settings.public_folder, "favicon.ico") allow(File).to receive(:file?).and_call_original allow(File).to receive(:file?).with(ico_path).and_return(false) get "/favicon.ico" expect(last_response).to be_ok expect(last_response.headers["Content-Type"]).to eq("image/svg+xml") end end describe "GET /potatomesh-logo.svg" do it "serves the cached SVG asset when present" do get "/potatomesh-logo.svg" expect(last_response).to be_ok expect(last_response.headers["Content-Type"]).to eq("image/svg+xml") end it "returns 404 when the asset is missing" do svg_path = File.expand_path("potatomesh-logo.svg", Sinatra::Application.settings.public_folder) allow(File).to receive(:exist?).and_return(false) allow(File).to receive(:readable?).and_return(false) get "/potatomesh-logo.svg" expect(last_response.status).to eq(404) end end describe "GET /" do it "responds successfully" do get "/" expect(last_response).to be_ok end it "includes the application version in the footer" do get "/" expect(last_response.body).to include("#{APP_VERSION}") end it "renders the responsive footer container" do get "/" expect(last_response.body).to include('