# 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 "json" require "time" RSpec.describe "Multi-protocol support" do let(:app) { Sinatra::Application } let(:api_token) { "test-token" } let(:auth_headers) do { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{api_token}", } end let(:now) { Time.now.to_i } MESHCORE_INGESTOR_ID = "!11223344".freeze ALT_NODE_ID = "!aabbccdd".freeze ALT_NODE_ID2 = "!ccddee00".freeze MESH_NODE_ID = "!mesh0001".freeze CORE_NODE_ID = "!core0001".freeze MESH_INGESTOR_ID = "!mesh9999".freeze SELECT_INGESTOR_PROTOCOL_SQL = "SELECT protocol FROM ingestors WHERE node_id = ?".freeze before do @original_token = ENV.fetch("API_TOKEN", nil) ENV["API_TOKEN"] = api_token clear_tables end after do ENV["API_TOKEN"] = @original_token clear_tables end # Open a database connection for direct inspection. # # @param readonly [Boolean] whether to open in read-only mode. # @yieldparam db [SQLite3::Database] open database handle. # @return [void] def with_db(readonly: false) db = PotatoMesh::Application.open_database(readonly: readonly) db.results_as_hash = true yield db ensure db&.close end # Remove all rows from tables exercised by these tests. # # @return [void] def clear_tables with_db do |db| db.execute("DELETE FROM trace_hops") db.execute("DELETE FROM traces") db.execute("DELETE FROM neighbors") db.execute("DELETE FROM messages") db.execute("DELETE FROM positions") db.execute("DELETE FROM telemetry") db.execute("DELETE FROM nodes") db.execute("DELETE FROM ingestors") end end # Register an ingestor via the API and return the response. # # @param node_id [String] canonical ingestor node identifier. # @param protocol [String, nil] mesh protocol string; omit to test default. # @return [Rack::MockResponse] the POST response. def register_ingestor(node_id, protocol: nil) payload = { node_id: node_id, start_time: now - 60, last_seen_time: now, version: "0.5.12", } payload[:protocol] = protocol if protocol post "/api/ingestors", payload.to_json, auth_headers last_response end describe "POST /api/ingestors" do it "stores protocol when provided" do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID]) expect(row["protocol"]).to eq("meshcore") end end it "defaults protocol to meshtastic when field is absent" do register_ingestor("!aabbccdd") expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, ["!aabbccdd"]) expect(row["protocol"]).to eq("meshtastic") end end it "updates protocol on re-registration" do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshtastic") register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") with_db(readonly: true) do |db| row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID]) expect(row["protocol"]).to eq("meshcore") end end end describe "protocol propagation to event tables" do before do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") end it "writes meshcore protocol to messages that reference a meshcore ingestor" do msg = { id: 42, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "hello from meshcore", ingestor: MESHCORE_INGESTOR_ID, } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [42]) expect(row["protocol"]).to eq("meshcore") end end it "writes meshcore protocol to positions that reference a meshcore ingestor" do pos = { id: 100, rx_time: now - 5, rx_iso: Time.at(now - 5).utc.iso8601, node_id: ALT_NODE_ID, latitude: 1.0, longitude: 2.0, ingestor: MESHCORE_INGESTOR_ID, } post "/api/positions", [pos].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM positions WHERE id = ?", [100]) expect(row["protocol"]).to eq("meshcore") end end it "writes meshcore protocol to telemetry that references a meshcore ingestor" do tel = { id: 200, rx_time: now - 5, rx_iso: Time.at(now - 5).utc.iso8601, node_id: ALT_NODE_ID, battery_level: 80, ingestor: MESHCORE_INGESTOR_ID, } post "/api/telemetry", [tel].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM telemetry WHERE id = ?", [200]) expect(row["protocol"]).to eq("meshcore") end end it "writes meshcore protocol to traces that reference a meshcore ingestor" do trace = { id: 300, src: 0x11223344, dest: 0xaabbccdd, rx_time: now - 5, rx_iso: Time.at(now - 5).utc.iso8601, hops: [], ingestor: MESHCORE_INGESTOR_ID, } post "/api/traces", [trace].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM traces WHERE id = ?", [300]) expect(row["protocol"]).to eq("meshcore") end end it "uses protocol-derived long_name for auto-created placeholder nodes" do msg = { id: 43, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, from_id: "!11223300", text: "unknown sender", ingestor: MESHCORE_INGESTOR_ID, } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT long_name FROM nodes WHERE node_id = ?", ["!11223300"]) expect(row["long_name"]).to eq("Meshcore 3300") end end it "does not merge a message update from a different protocol" do msg = { id: 500, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "meshcore original", ingestor: MESHCORE_INGESTOR_ID, } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) # Meshtastic ingestor posts same ID — should be ignored meshtastic_msg = { id: 500, rx_time: now - 5, rx_iso: Time.at(now - 5).utc.iso8601, text: "meshtastic impostor", } post "/api/messages", [meshtastic_msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [500]) expect(row["text"]).to eq("meshcore original") expect(row["protocol"]).to eq("meshcore") end end it "does not overwrite a meshcore message via the constraint-fallback path" do # Seed the message directly in the DB so the first INSERT triggers a # constraint exception, exercising the rescue SQLite3::ConstraintException # fallback path rather than the primary update branch. with_db do |db| db.execute( "INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)", [501, now - 20, Time.at(now - 20).utc.iso8601, "meshcore seeded", "meshcore"], ) end # A Meshtastic payload arrives with the same packet ID and new text. # The fallback path must not overwrite the existing meshcore record. meshtastic_msg = { id: 501, rx_time: now - 5, rx_iso: Time.at(now - 5).utc.iso8601, text: "meshtastic fallback attempt", } post "/api/messages", [meshtastic_msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [501]) expect(row["text"]).to eq("meshcore seeded") expect(row["protocol"]).to eq("meshcore") end end end describe "POST /api/nodes with ingestor key" do it "inherits protocol from registered ingestor" do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") with_db do |db| db.execute( "INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES(?,?,?,?)", [ALT_NODE_ID, 0xaabbccdd, now - 100, now - 200], ) end payload = { ALT_NODE_ID => { "num" => 0xaabbccdd, "lastHeard" => now - 10 }, "ingestor" => MESHCORE_INGESTOR_ID, } post "/api/nodes", payload.to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID]) expect(row["protocol"]).to eq("meshcore") end end it "defaults to meshtastic when ingestor key is absent" do with_db do |db| db.execute( "INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES(?,?,?,?)", [ALT_NODE_ID2, 0xccddee00, now - 100, now - 200], ) end payload = { ALT_NODE_ID2 => { "num" => 0xccddee00, "lastHeard" => now - 10 } } post "/api/nodes", payload.to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID2]) expect(row["protocol"]).to eq("meshtastic") end end it "does not count the ingestor key against the node batch limit" do # Build exactly 1000 node entries plus the ingestor key — should succeed nodes = (1..1000).each_with_object({}) do |i, h| h[format("!%08x", i)] = { "num" => i, "lastHeard" => now - 1 } end nodes["ingestor"] = MESHCORE_INGESTOR_ID post "/api/nodes", nodes.to_json, auth_headers expect(last_response.status).to eq(201) end it "does not count the wrapper-level protocol key against the node batch limit" do # 1000 nodes + ingestor + protocol = 1002 wrapper keys but only 1000 # actual nodes — the limit check must skip both metadata keys. nodes = (1..1000).each_with_object({}) do |i, h| h[format("!%08x", i)] = { "num" => i, "lastHeard" => now - 1 } end nodes["ingestor"] = MESHCORE_INGESTOR_ID nodes["protocol"] = "meshcore" post "/api/nodes", nodes.to_json, auth_headers expect(last_response.status).to eq(201) end end describe "GET ?protocol= filter" do before do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") with_db do |db| db.execute( "INSERT INTO nodes(node_id, num, last_heard, first_heard, protocol) VALUES(?,?,?,?,?)", [MESH_NODE_ID, 1, now - 10, now - 20, "meshtastic"], ) db.execute( "INSERT INTO nodes(node_id, num, last_heard, first_heard, protocol) VALUES(?,?,?,?,?)", [CORE_NODE_ID, 2, now - 10, now - 20, "meshcore"], ) db.execute( "INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)", [1001, now - 5, Time.at(now - 5).utc.iso8601, "meshtastic msg", "meshtastic"], ) db.execute( "INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)", [1002, now - 5, Time.at(now - 5).utc.iso8601, "meshcore msg", "meshcore"], ) db.execute( "INSERT INTO positions(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)", [2001, now - 5, Time.at(now - 5).utc.iso8601, MESH_NODE_ID, "meshtastic"], ) db.execute( "INSERT INTO positions(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)", [2002, now - 5, Time.at(now - 5).utc.iso8601, CORE_NODE_ID, "meshcore"], ) db.execute( "INSERT INTO neighbors(node_id, neighbor_id, rx_time, protocol) VALUES(?,?,?,?)", [MESH_NODE_ID, CORE_NODE_ID, now - 5, "meshtastic"], ) db.execute( "INSERT INTO neighbors(node_id, neighbor_id, rx_time, protocol) VALUES(?,?,?,?)", [CORE_NODE_ID, MESH_NODE_ID, now - 5, "meshcore"], ) db.execute( "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)", [3001, now - 5, Time.at(now - 5).utc.iso8601, MESH_NODE_ID, "meshtastic"], ) db.execute( "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)", [3002, now - 5, Time.at(now - 5).utc.iso8601, CORE_NODE_ID, "meshcore"], ) db.execute( "INSERT INTO traces(id, rx_time, rx_iso, protocol) VALUES(?,?,?,?)", [4001, now - 5, Time.at(now - 5).utc.iso8601, "meshtastic"], ) db.execute( "INSERT INTO traces(id, rx_time, rx_iso, protocol) VALUES(?,?,?,?)", [4002, now - 5, Time.at(now - 5).utc.iso8601, "meshcore"], ) end end it "filters /api/nodes by protocol" do get "/api/nodes?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["node_id"] } expect(ids).to include(CORE_NODE_ID) expect(ids).not_to include(MESH_NODE_ID) end it "filters /api/messages by protocol" do get "/api/messages?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) texts = JSON.parse(last_response.body).map { |r| r["text"] } expect(texts).to include("meshcore msg") expect(texts).not_to include("meshtastic msg") end it "filters /api/positions by protocol" do get "/api/positions?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["id"] } expect(ids).to include(2002) expect(ids).not_to include(2001) end it "filters /api/neighbors by protocol" do get "/api/neighbors?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) rows = JSON.parse(last_response.body) expect(rows.any? { |r| r["node_id"] == CORE_NODE_ID }).to be(true) expect(rows.none? { |r| r["node_id"] == MESH_NODE_ID }).to be(true) end it "filters /api/telemetry by protocol" do get "/api/telemetry?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["id"] } expect(ids).to include(3002) expect(ids).not_to include(3001) end it "filters /api/traces by protocol" do get "/api/traces?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["id"] } expect(ids).to include(4002) expect(ids).not_to include(4001) end it "filters /api/ingestors by protocol" do with_db do |db| db.execute( "INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)", [MESH_INGESTOR_ID, now - 60, now, "0.5.12", "meshtastic"], ) end get "/api/ingestors?protocol=meshcore", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["node_id"] } expect(ids).to include(MESHCORE_INGESTOR_ID) expect(ids).not_to include(MESH_INGESTOR_ID) end it "returns all records when protocol param is absent" do get "/api/nodes", {}, auth_headers expect(last_response.status).to eq(200) ids = JSON.parse(last_response.body).map { |r| r["node_id"] } expect(ids).to include(MESH_NODE_ID) expect(ids).to include(CORE_NODE_ID) end it "includes protocol field in GET /api/messages responses" do get "/api/messages", {}, auth_headers expect(last_response.status).to eq(200) rows = JSON.parse(last_response.body) expect(rows.all? { |r| r.key?("protocol") }).to be(true) end it "includes protocol field in GET /api/nodes responses" do get "/api/nodes", {}, auth_headers expect(last_response.status).to eq(200) rows = JSON.parse(last_response.body) expect(rows.all? { |r| r.key?("protocol") }).to be(true) end end describe "backward compatibility" do it "existing payloads without protocol field default to meshtastic" do msg = { id: 999, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "legacy message", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [999]) expect(row["protocol"]).to eq("meshtastic") end end it "existing ingestor registrations without protocol default to meshtastic in GET responses" do with_db do |db| db.execute( "INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)", ["!legacy00", now - 120, now - 10, "0.5.0", "meshtastic"], ) end get "/api/ingestors", {}, auth_headers expect(last_response.status).to eq(200) entry = JSON.parse(last_response.body).find { |r| r["node_id"] == "!legacy00" } expect(entry["protocol"]).to eq("meshtastic") end end # Coverage for the per-record protocol stamp that closes the startup race # where the web app processes a MeshCore message before the corresponding # ingestor heartbeat has registered a protocol mapping — see CONTRACTS.md. describe "per-record protocol override" do it "honors explicit message[\"protocol\"] when ingestor is unregistered" do msg = { id: 6001, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, from_id: "!aabbcc01", text: "explicit meshcore stamp", ingestor: "!unregistered000", protocol: "meshcore", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| message_row = db.get_first_row( "SELECT protocol FROM messages WHERE id = ?", [6001], ) expect(message_row["protocol"]).to eq("meshcore") node_row = db.get_first_row( "SELECT long_name, role, protocol FROM nodes WHERE node_id = ?", ["!aabbcc01"], ) # ``canonical_node_parts`` uppercases hex letters in the short_id; digit-only # short_ids (like the ``3300`` case earlier in this file) are unaffected. expect(node_row["long_name"]).to eq("Meshcore CC01") expect(node_row["role"]).to eq("COMPANION") expect(node_row["protocol"]).to eq("meshcore") end end it "honors explicit protocol on /api/nodes wrapper when ingestor is unregistered" do payload = { "!11aabbcc" => { "num" => 0x11aabbcc, "lastHeard" => now - 5 }, "ingestor" => "!unregistered000", "protocol" => "meshcore", } post "/api/nodes", payload.to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!11aabbcc"]) expect(row["protocol"]).to eq("meshcore") end end it "lets per-node protocol override wrapper batch protocol" do payload = { "!22aabbcc" => { "num" => 0x22aabbcc, "lastHeard" => now - 5, "protocol" => "meshcore", }, "ingestor" => "!unregistered000", "protocol" => "meshtastic", } post "/api/nodes", payload.to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!22aabbcc"]) expect(row["protocol"]).to eq("meshcore") end end it "falls back to ingestor lookup when explicit protocol is malformed" do register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore") msg = { id: 6002, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "garbage protocol value", ingestor: MESHCORE_INGESTOR_ID, protocol: "reticulum", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6002]) expect(row["protocol"]).to eq("meshcore") end end it "falls back to meshtastic when no explicit protocol and ingestor is unregistered" do msg = { id: 6003, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "no stamp, no ingestor", ingestor: "!unregistered000", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6003]) expect(row["protocol"]).to eq("meshtastic") end end it "normalises mixed-case and whitespace in protocol stamp" do msg = { id: 6004, rx_time: now - 10, rx_iso: Time.at(now - 10).utc.iso8601, text: "case normalisation", ingestor: "!unregistered000", protocol: " MeshCore ", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) with_db(readonly: true) do |db| row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6004]) expect(row["protocol"]).to eq("meshcore") end end end end