Files

8989 lines
326 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 "base64"
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 }
INSERT_NODE_WITH_LAST_HEARD_SQL = "INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES (?,?,?,?)".freeze
INSERT_NODE_WITH_METADATA_SQL = "INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)".freeze
SELECT_NODE_LAST_HEARD_SQL = "SELECT last_heard FROM nodes WHERE node_id = ?".freeze
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 trace_hops")
db.execute("DELETE FROM traces")
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")
db.execute("DELETE FROM ingestors")
db.execute("DELETE FROM ingestor_activity")
db.execute("DELETE FROM waypoints")
end
ensure_self_instance_record!
end
# Walk a bulk collection endpoint backward with the +before+ cursor the way an
# API client must, returning the set of unique record ids recovered. Generic
# over the route path, the id field used for client-side de-duplication, and
# the primary sort field the cursor advances on (SPEC BP1-BP3).
#
# @param path [String] collection route, e.g. ``"/api/positions"``.
# @param id_key [String] response field uniquely identifying a row.
# @param sort_key [String] response field the +before+ cursor bounds.
# @return [Array] the de-duplicated ids recovered across every page.
def walk_before(path, id_key:, sort_key:)
cap = PotatoMesh::App::Queries::MAX_QUERY_LIMIT
seen = {}
cursor = nil
pages = 0
loop do
url = "#{path}?limit=#{cap}"
url += "&before=#{cursor}" if cursor
get url
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
# The per-request cap is never exceeded by a single response.
expect(rows.size).to be <= cap
added = rows.reject { |row| seen.key?(row[id_key]) }
added.each { |row| seen[row[id_key]] = true }
pages += 1
break if rows.size < cap # window exhausted
break if added.empty? # no progress (server ignoring `before`)
break if pages >= 10 # hard safety bound against an infinite loop
cursor = rows.map { |row| row[sort_key] }.min
end
seen.keys
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.
#
# `position_time = 0` is a Meshtastic "no GPS lock" sentinel; the write
# boundary (`upsert_node`) normalises it to SQL `NULL` per issue #782, so
# the canonical expectation here mirrors that behaviour by collapsing zero
# to nil. Lat/lon are intentionally left untouched here because the
# fixture never contains the paired `(0, 0)` Null Island sentinel.
#
# @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)
raw_position_time = node["position_time"]
canonical_position_time = if raw_position_time.is_a?(Numeric) && raw_position_time <= 0
nil
else
raw_position_time
end
{
"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" => canonical_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
# Canonical API view of a node fixture. Identical to +expected_node_row+
# now that the write boundary applies the same sentinel normalisation; the
# alias is preserved so future divergence (e.g. API-only field omission via
# +compact_api_row+) has a natural home.
#
# @param node [Hash] node attributes from the fixture dataset.
# @return [Hash] expected API response row for assertions.
def expected_api_node_row(node)
expected_node_row(node)
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
# Fetch the stored telemetry_type for a given row id and assert it equals
# +expected+. Avoids repeating the with_db / SELECT / expect triple across
# multiple telemetry_type inference tests.
#
# @param id [Integer] telemetry row id to look up.
# @param expected [String] expected telemetry_type value.
# @return [void]
def expect_stored_telemetry_type(id, expected)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT telemetry_type FROM telemetry WHERE id = ?", [id])
expect(row["telemetry_type"]).to eq(expected)
end
end
# Assert that an API response either omits blank values or matches the
# expected non-blank value.
#
# @param row [Hash] API response payload.
# @param key [String] attribute to inspect.
# @param expected [Object] canonical value from fixtures.
# @return [void]
def expect_api_value(row, key, expected)
if expected.is_a?(String) && expected.strip.empty?
expect(row).not_to have_key(key), "expected #{key} to be omitted"
elsif expected.nil?
expect(row).not_to have_key(key), "expected #{key} to be omitted"
else
expect_same_value(row[key], expected)
end
end
# Retrieve a telemetry metric from nested or top-level payload keys.
#
# @param entry [Hash] telemetry payload.
# @param keys [Array<String>] possible metric names, using snake_case.
# @return [Object, nil] discovered metric value or nil when absent.
def telemetry_metric(entry, *keys)
sources = [
entry,
entry["device_metrics"],
entry["deviceMetrics"],
entry["environment_metrics"],
entry["environmentMetrics"],
].compact
candidates = keys.flat_map { |key| [key, camelize_key(key)] }.compact.uniq
sources.each do |source|
candidates.each do |candidate|
return source[candidate] if source.key?(candidate)
end
end
nil
end
# Convert a snake_case key to camelCase for fixture lookups.
#
# @param key [String] snake_case key.
# @return [String, nil] camelCase variant or nil when not applicable.
def camelize_key(key)
return nil unless key.is_a?(String) && key.include?("_")
parts = key.split("_")
[parts.first, *parts[1..].map(&:capitalize)].join
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.status).to eq(201)
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.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
# Import a subset of position packets from the fixture file.
#
# @param limit [Integer] number of fixture entries to submit.
# @return [void]
def import_positions_fixture(limit: positions_fixture.size)
positions_fixture.first(limit).each do |position|
post "/api/positions", position.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
# Import a subset of instance records from the fixture file.
#
# @param limit [Integer] number of fixture entries to submit.
# @return [void]
def import_instances_fixture(limit: instances_fixture.size)
insert_sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
with_db do |db|
instances_fixture.first(limit).each do |instance|
db.execute(
insert_sql,
[
instance["id"],
instance["domain"],
instance["pubkey"],
instance["name"],
instance["version"],
instance["channel"],
instance["frequency"],
instance["latitude"],
instance["longitude"],
instance["lastUpdateTime"],
instance["isPrivate"] ? 1 : 0,
instance["signature"],
],
)
end
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(:positions_fixture) { JSON.parse(File.read(fixture_path("positions.json"))) }
let(:instances_fixture) { JSON.parse(File.read(fixture_path("instances.json"))) }
let(:trace_fixture) do
[
{
"id" => 9_001,
"request_id" => 17,
"src" => 2_658_361_180,
"dest" => 4_242_424_242,
"rx_time" => reference_time.to_i - 2,
"hops" => [2_658_361_180, 19_088_743, 4_242_424_242],
"rssi" => -83,
"snr" => 5.0,
"elapsed_ms" => 842,
},
{
"packet_id" => 9_002,
"req" => 21,
"from" => 19_088_743,
"destination" => 2_658_361_180,
"rx_time" => reference_time.to_i - 5,
"path" => [{ "node_id" => "0xbeadf00d" }, { "node_id" => 19_088_743 }],
"metrics" => { "snr" => 3.5, "latency_ms" => 1_020 },
},
]
end
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(app).to receive(:federation_sleep_with_shutdown).with(delay).and_return(true)
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
PotatoMesh::App::ApiCache.invalidate_all
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__) }
# The resolver now consults ENV["APP_VERSION"] (the version baked into the
# Docker image, #871). Neutralize any ambient value so the git/fallback
# examples stay deterministic — otherwise a run inside the web image (which
# ships `spec/` *and* sets `ENV APP_VERSION`) would short-circuit them. The
# baked-version examples set ENV["APP_VERSION"] themselves; this restores it.
around do |example|
saved = ENV.delete("APP_VERSION")
example.run
ensure
saved.nil? ? ENV.delete("APP_VERSION") : (ENV["APP_VERSION"] = saved)
end
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("v#{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("v#{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("v#{PotatoMesh::Config.version_fallback}")
end
it "returns the (v-prefixed) 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("vweird-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("v#{PotatoMesh::Config.version_fallback}")
end
it "prefers an explicit ENV['APP_VERSION'] (baked image version) over git describe (#871)" do
# A Docker image bakes its git version into ENV['APP_VERSION'] (SPEC AV1/
# AV6): it must win over the in-image git lookup so the ?v= buster is
# unique per build and AssetCacheControl can safely use `immutable`.
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])
ENV["APP_VERSION"] = "v0.7.4+9-baked12"
expect(application_class.determine_app_version).to eq("v0.7.4+9-baked12")
end
it "normalizes a raw 'git describe' baked into ENV['APP_VERSION'] to the canonical version" do
# CI passes `git describe --tags --long --abbrev=7` verbatim as the build
# arg; the baked version must render identically to a bare-metal checkout
# at the same commit (here: exactly on the tag -> the tag alone).
ENV["APP_VERSION"] = "v0.7.4-0-gabc1234"
expect(application_class.determine_app_version).to eq("v0.7.4")
end
it "ignores a blank ENV['APP_VERSION'] and falls back to git/version" 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])
ENV["APP_VERSION"] = " "
expect(application_class.determine_app_version).to eq("v1.2.3")
end
it "v-prefixes a bare baked ENV['APP_VERSION'] so every build shape matches" do
ENV["APP_VERSION"] = "0.7.9"
expect(application_class.determine_app_version).to eq("v0.7.9")
end
it "reports pinned=true for a baked ENV['APP_VERSION'] (immutable-safe)" do
ENV["APP_VERSION"] = "v0.7.4-0-gabc1234"
expect(application_class.app_version_pinned?).to be(true)
end
it "reports pinned=true when git describe succeeds (immutable-safe)" 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.app_version_pinned?).to be(true)
end
it "reports pinned=false and a v-prefixed fallback when git is unavailable" do
allow(application_class).to receive(:locate_git_repo_root).and_return(nil)
expect(application_class.app_version_pinned?).to be(false)
expect(application_class.determine_app_version).to eq("v#{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 "returns nil when the announcement is unset" do
allow(PotatoMesh::Config).to receive(:announcement).and_return(nil)
expect(announcement_html).to be_nil
end
it "renders announcement links with safe targets" do
allow(PotatoMesh::Config).to receive(:announcement).and_return("Visit https://example.org now.")
expect(announcement_html).to include(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer">https://example.org</a>',
)
end
it "escapes announcement text while preserving links" do
allow(PotatoMesh::Config).to receive(:announcement).and_return("<b>Hi</b> https://example.org")
expect(announcement_html).to include("&lt;b&gt;Hi&lt;/b&gt;")
expect(announcement_html).to include(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer">https://example.org</a>',
)
end
it "returns escaped announcement text when no links are present" do
allow(PotatoMesh::Config).to receive(:announcement).and_return("<hi>")
expect(announcement_html).to eq("&lt;hi&gt;")
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_NODE_WITH_LAST_HEARD_SQL,
[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_domain" do
around do |example|
original_app_env = ENV["APP_ENV"]
original_rack_env = ENV["RACK_ENV"]
begin
example.run
ensure
if original_app_env
ENV["APP_ENV"] = original_app_env
else
ENV.delete("APP_ENV")
end
if original_rack_env
ENV["RACK_ENV"] = original_rack_env
else
ENV.delete("RACK_ENV")
end
end
end
it "returns the sanitized domain when configuration is present" do
ENV.delete("APP_ENV")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ")
expect(application_class.self_instance_domain).to eq("example.org")
end
it "returns nil when the domain is unavailable outside production" do
ENV["APP_ENV"] = "development"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
expect(application_class.self_instance_domain).to be_nil
end
it "raises when the domain is unavailable in production" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
expect { application_class.self_instance_domain }.to raise_error(
RuntimeError,
"INSTANCE_DOMAIN could not be determined",
)
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)
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
it "rejects registration when the domain is invalid" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
allowed, reason = application_class.self_instance_registration_decision(nil)
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
end
it "rejects registration when the domain resolves to a restricted IP" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
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
it "accepts registration when configuration is valid" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
allowed, reason = application_class.self_instance_registration_decision(domain)
expect(allowed).to be(true)
expect(reason).to be_nil
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)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh")
with_db do |db|
db.execute("DELETE FROM instances")
end
application_class.ensure_self_instance_record!
expect(instance_count).to eq(1)
end
it "skips persistence when registration is not allowed" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns)
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
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")
seed_domains = PotatoMesh::Config.federation_seed_domains.map(&:downcase)
expect(targets.first(seed_domains.length)).to eq(seed_domains)
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::Config.federation_seed_domains.map(&:downcase))
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::Config.federation_seed_domains.map(&:downcase))
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
it "ignores opted-out nodes when computing the freshness hint" do
marker = PotatoMesh::Config.node_opt_out_marker
with_db do |db|
db.execute("DELETE FROM nodes")
db.execute(
"INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)",
["!visible", "Visible Node", 100],
)
db.execute(
"INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)",
["!silenced", "Silenced #{marker} Node", 500],
)
end
expect(application_class.latest_node_update_timestamp).to eq(100)
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
# NOTE: stub_const does not take a block (the previous block form silently
# skipped every assertion below). Stub for the example, then assert inline.
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET")
json_output, signature = application_class.build_well_known_document
document = JSON.parse(json_output)
# v2 well-known is snake_case with a signature_version marker (SPEC FS1/FS3).
expect(document["domain"]).to eq("example.net")
expect(document["last_update"]).to eq(321)
expect(document["signature_algorithm"]).to eq("rsa-sha256")
expect(document["signature_version"]).to eq(2)
expect(document["public_key"]).to eq(application_class::INSTANCE_PUBLIC_KEY_PEM)
expect(document).not_to have_key("lastUpdate")
expect(document).not_to have_key("signatureAlgorithm")
expect(signature).to be_a(String)
expect(signature).not_to be_empty
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 INFO when debug logging is disabled" do
# WARN-only hid operational milestones (e.g. retention purges already
# promoted to info_log) and made federation cycles invisible to
# operators. INFO is the documented default; DEBUG=1 raises verbosity.
expect(Sinatra::Application.settings.logger.level).to eq(Logger::INFO)
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")
# Static-served straight off public/ (no Cache-Control); the
# AssetCacheControl middleware stamps a bounded one so it is not
# revalidated on every page load.
expect(last_response.headers["Cache-Control"]).to eq(PotatoMesh::App::AssetCacheControl::ICON_CACHE_CONTROL)
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")
# The logo is the site icon on every page and is static-served off public/
# (no Cache-Control); the middleware stamps a bounded one so it is cached
# rather than revalidated each load.
expect(last_response.headers["Cache-Control"]).to eq(PotatoMesh::App::AssetCacheControl::ICON_CACHE_CONTROL)
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 "static asset caching (frontend perf)" do
it "serves a version-busted asset with a long-lived Cache-Control" do
# Returning/staying visitors serve versioned JS/CSS from cache instead of
# revalidating every asset each navigation (the AssetCacheControl middleware).
# The value is `immutable` only for a pinned build — a version unique per
# build (baked-ENV or git-derived); a fallback-version build — e.g. CI's
# shallow checkout with no tags, or a Docker image without .git and no baked
# version — correctly gets the bounded, revalidatable form instead (the same
# condition the app wires at boot via APP_VERSION_PINNED).
get "/assets/js/app/main.js?v=testver"
expect(last_response).to be_ok
expected = PotatoMesh::Application::APP_VERSION_PINNED ?
PotatoMesh::App::AssetCacheControl::IMMUTABLE_CACHE_CONTROL :
PotatoMesh::App::AssetCacheControl::REVALIDATABLE_CACHE_CONTROL
expect(last_response.headers["Cache-Control"]).to eq(expected)
end
it "does not immutable-cache an unversioned asset (AV4 keeps revalidation)" do
get "/assets/img/meshcore.svg"
expect(last_response).to be_ok
expect(last_response.headers["Cache-Control"].to_s).not_to include("immutable")
end
end
describe "GET /" do
it "responds successfully" do
get "/"
expect(last_response).to be_ok
end
it "modulepreloads the dashboard's own module graph but not other pages' entries" do
# Frontend perf regression: the layout preloaded *every* served module on
# every page, so a page eagerly downloaded the entry graphs of the other
# pages it never runs. The preload set must be scoped to the current view's
# own module graph; the import map (AV3) still versions the whole graph, so
# cache-busting is unaffected.
get "/"
expect(last_response).to be_ok
# The dashboard's own graph (index.js → main.js → …) is still preloaded.
expect(last_response.body).to include('rel="modulepreload" href="/assets/js/app/main.js?v=')
# Other pages' entry modules — not reachable from the dashboard graph — must
# NOT be preloaded on the dashboard (the /charts and /federation entries).
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/charts-page.js?v=')
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/federation-page.js?v=')
# AV3 unchanged: the import map still version-stamps the *whole* graph, so a
# later navigation to those pages still gets cache-busted modules.
expect(last_response.body).to include('"/assets/js/app/charts-page.js":"/assets/js/app/charts-page.js?v=')
end
it "keeps the lazily-loaded node-detail overlay subtree out of the boot preload" do
# Frontend perf: the click-to-open node overlay reuses the heavy node-detail
# renderer (node-page.js → node-page-charts). It is dynamic-`import()`ed on
# first open, so it must NOT be in the dashboard's synchronous boot preload
# (it still loads on demand, and the import map still versions it).
get "/"
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/node-page.js?v=')
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/node-detail-overlay.js?v=')
# Still versioned in the import map for the on-demand load (AV3).
expect(last_response.body).to include('"/assets/js/app/node-page.js":"/assets/js/app/node-page.js?v=')
end
it "loads the CDN Leaflet script deferred so it does not block first paint" do
# Frontend perf regression: Leaflet was a synchronous external <script> in
# <head>, so first paint blocked on the round-trip to unpkg. It must be
# `defer` — the map init runs on DOMContentLoaded, after deferred scripts,
# so Leaflet is still ready in time.
get "/"
leaflet_tag = last_response.body[%r{<script[^>]*leaflet[^>]*>}i]
expect(leaflet_tag).not_to be_nil
expect(leaflet_tag).to include("defer")
end
it "preconnects to the Leaflet and both tile-CDN origins (LCP critical path)" do
# The LCP element is a map tile requested only after Leaflet loads + inits;
# warming the Leaflet CDN plus both always-on tile hosts (CARTO base +
# HOT overlay) early trims that resource-load delay.
get "/"
expect(last_response.body).to include(%(<link rel="preconnect" href="https://unpkg.com" crossorigin />))
expect(last_response.body).to include(%(<link rel="preconnect" href="https://a.basemaps.cartocdn.com" crossorigin />))
expect(last_response.body).to include(%(<link rel="preconnect" href="https://a.tile.openstreetmap.fr" crossorigin />))
end
it "does not render the Refresh button or last-updated field" do
get "/"
expect(last_response).to be_ok
# Live SSE updates replace manual refresh + a timestamp (VF1).
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).not_to include('class="refresh-timestamp"')
# The play/pause toggle is kept.
expect(last_response.body).to include('id="autorefreshToggle"')
end
it "includes the application version in the footer" do
get "/"
expected = APP_VERSION.to_s.start_with?("v") ? APP_VERSION : "v#{APP_VERSION}"
expect(last_response.body).to include(expected)
end
it "renders the responsive footer container" do
get "/"
expect(last_response.body).to include('<footer class="app-footer">')
expect(last_response.body).to include('class="footer-content"')
end
it "renders the site title as a link to the dashboard" do
get "/"
expect(last_response.body).to include('class="site-title__link"')
expect(last_response.body).to match(%r{<a href="/" class="site-title__link">})
end
it "renders the federation instance selector when federation is enabled" do
get "/"
# SPEC UX11 (audit D-029/D-038): the selector sits behind a compact
# toggle and the placeholder names the action.
expect(last_response.body).to include('id="instanceSelect"')
expect(last_response.body).to include('id="instanceSelectToggle"')
expect(last_response.body).to include("Other regions…")
end
it "omits the instance selector when private mode is active" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "omits the instance selector when federation is disabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "includes SEO metadata from configuration" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title")
allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel")
allow(PotatoMesh::Config).to receive(:frequency).and_return("915MHz")
allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(120.5)
allow(PotatoMesh::Config).to receive(:contact_link).and_return(" #spec-room:example.org ")
expected_description = "Live Meshtastic mesh map for Spec Mesh Title on #SpecChannel (915MHz). Track nodes, messages, and coverage in real time. Shows nodes within roughly 120.5 km of the map center. Join the community in #spec-room:example.org via chat."
get "/"
expect(last_response.body).to include(%(meta name="description" content="#{expected_description}" />))
expect(last_response.body).to include('<meta property="og:title" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta property="og:site_name" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta name="twitter:card" content="summary_large_image" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://spec.mesh.test/og-image.png" />')
expect(last_response.body).to include('<meta property="og:image:width" content="1200" />')
expect(last_response.body).to include('<meta property="og:image:height" content="630" />')
end
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
get "/"
expect(last_response.body).not_to include('id="fitBounds"')
end
end
describe "GET /map" do
it "renders the map in full-screen mode with filter controls" do
get "/map"
expect(last_response).to be_ok
expect(last_response.body).to include('class="map-panel map-panel--full"')
expect(last_response.body).to include('id="map"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('id="fitBounds"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
end
describe "GET /federation" do
it "returns 404 when federation is disabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/federation"
expect(last_response.status).to eq(404)
end
it "renders the federation subpage when enabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="federation-page"')
expect(last_response.body).to include("initializeFederationPage")
end
it "hides the meta-controls row entirely on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="themeToggle"')
expect(last_response.body).not_to include('id="metaRow"')
end
it "renders the opaque footer chrome on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
# Audit follow-up 08: the transparent slim variant is retired; every route
# uses the one opaque footer chrome so it never floats over body copy.
expect(last_response.body).to include('class="app-footer"')
expect(last_response.body).not_to include("app-footer--slim")
end
end
describe "GET /chat" do
it "renders the chat container when chat is enabled" do
get "/chat"
expect(last_response).to be_ok
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
it "shows a disabled message when private mode is active" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/chat"
expect(last_response).to be_ok
expect(last_response.body).to include("Chat is unavailable while private mode is enabled.")
end
end
describe "GET /nodes" do
it "renders the nodes table in full-screen mode" do
get "/nodes"
expect(last_response).to be_ok
expect(last_response.body).to include('class="nodes-table-wrapper"')
expect(last_response.body).to include('id="nodes"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
end
describe "GET /charts" do
it "renders the charts page with the opaque footer but without meta-controls" do
get "/charts"
expect(last_response).to be_ok
expect(last_response.body).to include("initializeChartsPage")
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="filterInput"')
# Audit follow-up 08: no more slim footer variant.
expect(last_response.body).to include('class="app-footer"')
expect(last_response.body).not_to include("app-footer--slim")
end
end
describe "database initialization" do
it "creates the schema when booting" do
expect(File).to exist(PotatoMesh::Config.db_path)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true)
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('nodes','messages')").flatten
expect(tables).to include("nodes")
expect(tables).to include("messages")
ensure
db&.close
end
end
describe "authentication" do
it "rejects requests without a matching bearer token" do
post "/api/nodes", {}.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(403)
expect(JSON.parse(last_response.body)).to eq("error" => "Forbidden")
end
it "rejects requests when the API token is not configured" do
ENV["API_TOKEN"] = nil
post "/api/messages", {}.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(403)
expect(JSON.parse(last_response.body)).to eq("error" => "Forbidden")
ensure
ENV["API_TOKEN"] = api_token
end
it "rejects requests with the wrong bearer token" do
headers = auth_headers.merge("HTTP_AUTHORIZATION" => "Bearer wrong-token")
post "/api/messages", {}.to_json, headers
expect(last_response.status).to eq(403)
expect(JSON.parse(last_response.body)).to eq("error" => "Forbidden")
end
it "does not accept alternate authorization schemes" do
basic = Base64.strict_encode64("attacker:password")
headers = auth_headers.merge("HTTP_AUTHORIZATION" => "Basic #{basic}")
post "/api/nodes", {}.to_json, headers
expect(last_response.status).to eq(403)
expect(JSON.parse(last_response.body)).to eq("error" => "Forbidden")
end
it "rejects tokens with unexpected trailing characters" do
headers = auth_headers.merge("HTTP_AUTHORIZATION" => "Bearer #{api_token} ")
post "/api/messages", {}.to_json, headers
expect(last_response.status).to eq(403)
expect(JSON.parse(last_response.body)).to eq("error" => "Forbidden")
end
end
describe "POST /api/instances" do
let(:instance_key) { OpenSSL::PKey::RSA.new(2048) }
let(:domain) { "mesh.example" }
let(:pubkey) { instance_key.public_key.export }
let(:last_update_time) { Time.now.to_i }
let(:instance_attributes) do
{
id: "mesh-instance-1",
domain: domain,
pubkey: pubkey,
name: "Example Mesh",
version: "1.2.3",
channel: "#MeshNet",
frequency: "915MHz",
latitude: 52.5,
longitude: 13.4,
last_update_time: last_update_time,
is_private: false,
}
end
let(:instance_signature_payload) do
canonical_instance_payload(instance_attributes)
end
let(:instance_signature) do
Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, instance_signature_payload),
)
end
let(:instance_payload) do
{
"id" => instance_attributes[:id],
"domain" => domain,
"pubkey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"channel" => instance_attributes[:channel],
"frequency" => instance_attributes[:frequency],
"latitude" => instance_attributes[:latitude],
"longitude" => instance_attributes[:longitude],
"lastUpdateTime" => instance_attributes[:last_update_time],
"isPrivate" => instance_attributes[:is_private],
"signature" => instance_signature,
}
end
let(:remote_signed_payload) do
JSON.generate(
{
"publicKey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"domain" => domain,
"lastUpdate" => last_update_time,
},
sort_keys: true,
)
end
let(:well_known_document) do
{
"publicKey" => pubkey,
"domain" => domain,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"lastUpdate" => last_update_time,
"signatureAlgorithm" => "rsa-sha256",
"signedPayload" => Base64.strict_encode64(remote_signed_payload),
"signature" => Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, remote_signed_payload),
),
}
end
let(:remote_nodes) do
now = Time.now.to_i
Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{
"node_id" => "remote-node-#{index}",
"last_heard" => now - index,
}
end
end
# Stub fetch_instance_json on both the instance and class to return the
# supplied nodes array for /api/nodes requests.
def stub_remote_nodes(nodes)
fetch_stub = lambda do |host, path|
case path
when "/.well-known/potato-mesh"
[well_known_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
fetch_stub.call(host, path)
end
allow(PotatoMesh::Application).to receive(:fetch_instance_json) do |host, path|
fetch_stub.call(host, path)
end
end
before do
stub_remote_nodes(remote_nodes)
allow_any_instance_of(Sinatra::Application).to receive(:enqueue_federation_crawl) do |instance, domain, per_response_limit:, overall_limit:|
db = instance.open_database
begin
instance.ingest_known_instances_from!(
db,
domain,
per_response_limit: per_response_limit,
overall_limit: overall_limit,
)
ensure
db&.close
end
true
end
end
it "accepts snake_case optional fields on POST /api/instances" do
contact = "#room:example.org"
signed_attrs = instance_attributes.merge(contact_link: contact)
signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, canonical_instance_payload(signed_attrs)),
)
# snake_case contact_link plus the existing camelCase keys — third-party
# callers may send either casing (the camelCase keys stay accepted too).
snake_payload = instance_payload.merge(
"contact_link" => contact,
"signature" => signature,
)
post "/api/instances", snake_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row["contact_link"]).to eq(contact)
end
end
it "stores a federated instance when validation succeeds" do
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT * FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["domain"]).to eq(domain)
expect(row["pubkey"]).to eq(pubkey)
expect(row["signature"]).to eq(instance_signature)
expect(row["is_private"]).to eq(0)
end
end
it "recomputes node counts from remote nodes including per-protocol breakdown" do
now = Time.now.to_i
nodes_with_protocols = [
{ "node_id" => "mc-1", "lastHeard" => now - 10, "protocol" => "meshcore" },
{ "node_id" => "mc-2", "lastHeard" => now - 20, "protocol" => "meshcore" },
{ "node_id" => "mt-1", "lastHeard" => now - 30, "protocol" => "meshtastic" },
{ "node_id" => "mt-2", "lastHeard" => now - 40, "protocol" => "meshtastic" },
{ "node_id" => "mt-3", "lastHeard" => now - 50, "protocol" => "meshtastic" },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 5, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (60 + i), "protocol" => "meshtastic" }
}
stub_remote_nodes(nodes_with_protocols)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row(
"SELECT nodes_count, meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expect(row[0]).to eq(nodes_with_protocols.length)
expect(row[1]).to eq(2)
expect(row[2]).to eq(nodes_with_protocols.length - 2)
end
end
it "excludes nodes with lastHeard older than remote_instance_max_node_age" do
now = Time.now.to_i
max_age = PotatoMesh::Config.remote_instance_max_node_age
mixed_nodes = [
{ "node_id" => "fresh-1", "lastHeard" => now - 10 },
{ "node_id" => "fresh-2", "lastHeard" => now - 100 },
{ "node_id" => "stale-1", "lastHeard" => now - max_age - 1 },
{ "node_id" => "stale-2", "lastHeard" => now - max_age - 3600 },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 4, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (200 + i) }
}
stub_remote_nodes(mixed_nodes)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
fresh_count = mixed_nodes.count { |n| n["lastHeard"] >= now - max_age }
expect(stored).to eq(fresh_count)
end
end
it "excludes nodes without a lastHeard timestamp" do
now = Time.now.to_i
nodes_with_gaps = [
{ "node_id" => "has-ts", "lastHeard" => now - 10 },
{ "node_id" => "no-ts" },
{ "node_id" => "null-ts", "lastHeard" => nil },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 3, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (20 + i) }
}
stub_remote_nodes(nodes_with_gaps)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expected = nodes_with_gaps.count { |n|
ts = n["lastHeard"]
ts.is_a?(Integer) && ts >= Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
}
expect(stored).to eq(expected)
end
end
it "honors the last_heard snake_case key fallback" do
now = Time.now.to_i
snake_case_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |i|
{ "node_id" => "sc-#{i}", "last_heard" => now - i }
end
stub_remote_nodes(snake_case_nodes)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expect(stored).to eq(snake_case_nodes.length)
end
end
it "skips non-Hash entries in the remote nodes array" do
now = Time.now.to_i
mixed_entries = [
{ "node_id" => "valid", "lastHeard" => now - 10 },
"not-a-hash",
42,
nil,
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 4, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (20 + i) }
}
stub_remote_nodes(mixed_entries)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
hash_count = mixed_entries.count { |n|
next false unless n.is_a?(Hash)
ts = n["lastHeard"]
ts.is_a?(Integer) && ts >= Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
}
expect(stored).to eq(hash_count)
end
end
it "accepts registrations when contactLink is part of the signed payload" do
contact_link = "https://example.test/contact"
linked_attributes = instance_attributes.merge(contact_link: contact_link)
linked_signature_payload = canonical_instance_payload(linked_attributes)
linked_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, linked_signature_payload),
)
linked_payload = instance_payload.merge(
"contactLink" => contact_link,
"signature" => linked_signature,
)
post "/api/instances", linked_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(linked_signature)
end
end
it "accepts instance announcement payloads produced by the application including contactLink" do
contact_link = "https://example.test/contact"
announcement_attributes = instance_attributes.merge(contact_link: contact_link)
announcement_signature = Base64.strict_encode64(
instance_key.sign(
OpenSSL::Digest::SHA256.new,
canonical_instance_payload(announcement_attributes),
),
)
announcement_payload = application_class.instance_announcement_payload(
announcement_attributes,
announcement_signature,
)
post "/api/instances", announcement_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(announcement_signature)
end
end
it "accepts signatures that omit contactLink for backwards compatibility" do
contact_link = "https://legacy.example/contact"
legacy_signature_payload = canonical_instance_payload(instance_attributes)
legacy_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, legacy_signature_payload),
)
legacy_payload = instance_payload.merge(
"contactLink" => contact_link,
"signature" => legacy_signature,
)
post "/api/instances", legacy_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(legacy_signature)
end
end
it "accepts mixed-case domains when the signature omits contactLink but the payload includes it" do
raw_domain = "Mesh.Example"
normalized_domain = raw_domain.downcase
contact_link = "https://mixed.example/contact"
mixed_attributes = instance_attributes.merge(domain: raw_domain)
mixed_signature_payload = canonical_instance_payload(mixed_attributes)
mixed_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_signature_payload),
)
mixed_payload = instance_payload.merge(
"domain" => raw_domain,
"contactLink" => contact_link,
"signature" => mixed_signature,
)
mixed_remote_payload = JSON.generate(
{
"publicKey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"domain" => normalized_domain,
"lastUpdate" => last_update_time,
},
sort_keys: true,
)
mixed_document = well_known_document.merge(
"domain" => normalized_domain,
"signedPayload" => Base64.strict_encode64(mixed_remote_payload),
"signature" => Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_remote_payload),
),
)
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case path
when "/.well-known/potato-mesh"
[mixed_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[remote_nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
post "/api/instances", mixed_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT domain, contact_link, signature FROM instances WHERE id = ?",
[mixed_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["domain"]).to eq(normalized_domain)
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(mixed_signature)
end
end
it "rejects registrations with invalid domains" do
invalid_payload = instance_payload.merge("domain" => "mesh-instance")
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", invalid_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: "mesh-instance",
reason: "invalid domain",
),
],
)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT COUNT(*) FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(stored).to eq(0)
end
end
it "rejects registrations with invalid signatures" do
invalid_payload = instance_payload.merge("signature" => Base64.strict_encode64("invalid"))
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", invalid_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid signature")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: domain,
reason: "invalid signature",
),
],
)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM instances")
expect(count).to eq(1)
end
end
it "rejects registrations when DNS resolves to restricted addresses" do
restricted_addrinfo = Addrinfo.ip("127.0.0.1")
allow(Addrinfo).to receive(:getaddrinfo).and_return([restricted_addrinfo])
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do
raise "fetch_instance_json should not be called for restricted domains"
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "restricted domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: domain,
reason: "restricted domain",
),
],
)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT COUNT(*) FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(stored).to eq(0)
end
end
it "accepts bracketed IPv6 domains" do
ipv6_domain = "[2001:db8::1]"
ipv6_attributes = instance_attributes.merge(domain: ipv6_domain)
ipv6_signature_payload = canonical_instance_payload(ipv6_attributes)
ipv6_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, ipv6_signature_payload),
)
ipv6_payload = instance_payload.merge(
"domain" => ipv6_domain,
"signature" => ipv6_signature,
)
ipv6_remote_payload = JSON.generate(
{
"publicKey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"domain" => ipv6_domain,
"lastUpdate" => last_update_time,
},
sort_keys: true,
)
ipv6_document = well_known_document.merge(
"domain" => ipv6_domain,
"signedPayload" => Base64.strict_encode64(ipv6_remote_payload),
"signature" => Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, ipv6_remote_payload),
),
)
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case path
when "/.well-known/potato-mesh"
[ipv6_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[remote_nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
post "/api/instances", ipv6_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
stored_domain = db.get_first_value(
"SELECT domain FROM instances WHERE id = ?",
[ipv6_attributes[:id]],
)
expect(stored_domain).to eq(ipv6_domain.downcase)
end
end
it "rejects registrations targeting restricted literal IPs when a port is supplied" do
restricted_domain = "127.0.0.1:8080"
restricted_attributes = instance_attributes.merge(domain: restricted_domain)
restricted_signature_payload = canonical_instance_payload(restricted_attributes)
restricted_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, restricted_signature_payload),
)
restricted_payload = instance_payload.merge(
"domain" => restricted_domain,
"signature" => restricted_signature,
)
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", restricted_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "restricted domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: restricted_domain,
reason: "restricted IP address",
resolved_ip: an_instance_of(IPAddr),
),
],
)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM instances")
expect(count).to eq(1)
end
end
it "ingests federation instances advertised by remote peers" do
ally_key = OpenSSL::PKey::RSA.new(2048)
ally_domain = "ally.mesh"
ally_attributes = {
id: "ally-instance-1",
domain: ally_domain,
pubkey: ally_key.public_key.export,
name: "Ally Mesh",
version: "2.0.0",
channel: "#Allies",
frequency: "433MHz",
latitude: 40.1,
longitude: -74.0,
last_update_time: Time.now.to_i,
is_private: false,
}
ally_signature_payload = canonical_instance_payload(ally_attributes)
ally_signature = Base64.strict_encode64(
ally_key.sign(OpenSSL::Digest::SHA256.new, ally_signature_payload),
)
ally_payload = {
"id" => ally_attributes[:id],
"domain" => ally_domain,
"pubkey" => ally_attributes[:pubkey],
"name" => ally_attributes[:name],
"version" => ally_attributes[:version],
"channel" => ally_attributes[:channel],
"frequency" => ally_attributes[:frequency],
"latitude" => ally_attributes[:latitude],
"longitude" => ally_attributes[:longitude],
"lastUpdateTime" => ally_attributes[:last_update_time],
"isPrivate" => ally_attributes[:is_private],
"signature" => ally_signature,
}
ally_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{ "node_id" => "ally-node-#{index}", "last_heard" => Time.now.to_i - index }
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case [host, path]
when [domain, "/.well-known/potato-mesh"]
[well_known_document, URI("https://#{host}#{path}")]
when [domain, "/api/nodes"]
[remote_nodes, URI("https://#{host}#{path}")]
when [domain, "/api/instances"]
[[ally_payload], URI("https://#{host}#{path}")]
when [ally_domain, "/api/nodes"]
[ally_nodes, URI("https://#{host}#{path}")]
when [ally_domain, "/api/instances"]
[[instance_payload], URI("https://#{host}#{path}")]
else
[nil, []]
end
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
ally_row = db.get_first_row(
"SELECT domain, signature FROM instances WHERE domain = ?",
[ally_domain],
)
remote_row = db.get_first_row(
"SELECT domain, signature FROM instances WHERE domain = ?",
[domain],
)
expect(ally_row).not_to be_nil
expect(ally_row["signature"]).to eq(ally_signature)
expect(remote_row).not_to be_nil
expect(remote_row["signature"]).to eq(instance_signature)
end
end
it "skips remote federation entries that fail validation" do
stale_key = OpenSSL::PKey::RSA.new(2048)
stale_domain = "stale.mesh"
stale_attributes = {
id: "stale-instance",
domain: stale_domain,
pubkey: stale_key.public_key.export,
name: "Stale Mesh",
version: "0.1.0",
channel: "#Stale",
frequency: "868MHz",
latitude: 10.0,
longitude: 20.0,
last_update_time: Time.now.to_i,
is_private: false,
}
stale_signature_payload = canonical_instance_payload(stale_attributes)
stale_signature = Base64.strict_encode64(
stale_key.sign(OpenSSL::Digest::SHA256.new, stale_signature_payload),
)
stale_payload = {
"id" => stale_attributes[:id],
"domain" => stale_domain,
"pubkey" => stale_attributes[:pubkey],
"name" => stale_attributes[:name],
"version" => stale_attributes[:version],
"channel" => stale_attributes[:channel],
"frequency" => stale_attributes[:frequency],
"latitude" => stale_attributes[:latitude],
"longitude" => stale_attributes[:longitude],
"lastUpdateTime" => stale_attributes[:last_update_time],
"isPrivate" => false,
"signature" => stale_signature,
}
private_key = OpenSSL::PKey::RSA.new(2048)
private_domain = "private.mesh"
private_attributes = {
id: "private-instance",
domain: private_domain,
pubkey: private_key.public_key.export,
name: "Private Mesh",
version: "3.0.0",
channel: "#Private",
frequency: "915MHz",
latitude: 0.0,
longitude: 0.0,
last_update_time: Time.now.to_i,
is_private: true,
}
private_signature_payload = canonical_instance_payload(private_attributes)
private_signature = Base64.strict_encode64(
private_key.sign(OpenSSL::Digest::SHA256.new, private_signature_payload),
)
private_payload = {
"id" => private_attributes[:id],
"domain" => private_domain,
"pubkey" => private_attributes[:pubkey],
"name" => private_attributes[:name],
"version" => private_attributes[:version],
"channel" => private_attributes[:channel],
"frequency" => private_attributes[:frequency],
"latitude" => private_attributes[:latitude],
"longitude" => private_attributes[:longitude],
"lastUpdateTime" => private_attributes[:last_update_time],
"isPrivate" => true,
"signature" => private_signature,
}
invalid_key = OpenSSL::PKey::RSA.new(2048)
invalid_payload = {
"id" => "invalid-instance",
"domain" => "invalid.mesh",
"pubkey" => invalid_key.public_key.export,
"name" => "Invalid Mesh",
"version" => "1.0.0",
"channel" => "#Invalid",
"frequency" => "915MHz",
"latitude" => 1.0,
"longitude" => 2.0,
"lastUpdateTime" => Time.now.to_i,
"isPrivate" => false,
"signature" => Base64.strict_encode64("bogus"),
}
unreachable_key = OpenSSL::PKey::RSA.new(2048)
unreachable_domain = "unreachable.mesh"
unreachable_attributes = {
id: "unreachable-instance",
domain: unreachable_domain,
pubkey: unreachable_key.public_key.export,
name: "Unreachable Mesh",
version: "6.0.0",
channel: "#Offline",
frequency: "915MHz",
latitude: 12.0,
longitude: 24.0,
last_update_time: Time.now.to_i,
is_private: false,
}
unreachable_signature_payload = canonical_instance_payload(unreachable_attributes)
unreachable_signature = Base64.strict_encode64(
unreachable_key.sign(OpenSSL::Digest::SHA256.new, unreachable_signature_payload),
)
unreachable_payload = {
"id" => unreachable_attributes[:id],
"domain" => unreachable_domain,
"pubkey" => unreachable_attributes[:pubkey],
"name" => unreachable_attributes[:name],
"version" => unreachable_attributes[:version],
"channel" => unreachable_attributes[:channel],
"frequency" => unreachable_attributes[:frequency],
"latitude" => unreachable_attributes[:latitude],
"longitude" => unreachable_attributes[:longitude],
"lastUpdateTime" => unreachable_attributes[:last_update_time],
"isPrivate" => false,
"signature" => unreachable_signature,
}
offline_domain = "offline.mesh"
offline_key = OpenSSL::PKey::RSA.new(2048)
offline_attributes = {
id: "offline-instance",
domain: offline_domain,
pubkey: offline_key.public_key.export,
name: "Offline Mesh",
version: "4.0.0",
channel: "#Offline",
frequency: "915MHz",
latitude: 5.0,
longitude: 6.0,
last_update_time: Time.now.to_i,
is_private: false,
}
offline_signature_payload = canonical_instance_payload(offline_attributes)
offline_signature = Base64.strict_encode64(
offline_key.sign(OpenSSL::Digest::SHA256.new, offline_signature_payload),
)
offline_payload = {
"id" => offline_attributes[:id],
"domain" => offline_domain,
"pubkey" => offline_attributes[:pubkey],
"name" => offline_attributes[:name],
"version" => offline_attributes[:version],
"channel" => offline_attributes[:channel],
"frequency" => offline_attributes[:frequency],
"latitude" => offline_attributes[:latitude],
"longitude" => offline_attributes[:longitude],
"lastUpdateTime" => offline_attributes[:last_update_time],
"isPrivate" => false,
"signature" => offline_signature,
}
restricted_domain = "127.0.0.1"
restricted_key = OpenSSL::PKey::RSA.new(2048)
restricted_attributes = {
id: "restricted-instance",
domain: restricted_domain,
pubkey: restricted_key.public_key.export,
name: "Restricted Mesh",
version: "5.0.0",
channel: "#Restricted",
frequency: "915MHz",
latitude: 9.0,
longitude: 9.0,
last_update_time: Time.now.to_i,
is_private: false,
}
restricted_signature_payload = canonical_instance_payload(restricted_attributes)
restricted_signature = Base64.strict_encode64(
restricted_key.sign(OpenSSL::Digest::SHA256.new, restricted_signature_payload),
)
restricted_payload = {
"id" => restricted_attributes[:id],
"domain" => restricted_domain,
"pubkey" => restricted_attributes[:pubkey],
"name" => restricted_attributes[:name],
"version" => restricted_attributes[:version],
"channel" => restricted_attributes[:channel],
"frequency" => restricted_attributes[:frequency],
"latitude" => restricted_attributes[:latitude],
"longitude" => restricted_attributes[:longitude],
"lastUpdateTime" => restricted_attributes[:last_update_time],
"isPrivate" => false,
"signature" => restricted_signature,
}
stale_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{ "node_id" => "stale-node-#{index}", "last_heard" => (Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age) - index - 1 }
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case [host, path]
when [domain, "/.well-known/potato-mesh"]
[well_known_document, URI("https://#{host}#{path}")]
when [domain, "/api/nodes"]
[remote_nodes, URI("https://#{host}#{path}")]
when [domain, "/api/instances"]
[
[
"unexpected",
private_payload,
invalid_payload,
offline_payload,
stale_payload,
restricted_payload,
unreachable_payload,
],
URI("https://#{host}#{path}"),
]
when [offline_domain, "/api/nodes"]
[nil, ["timeout"]]
when [stale_domain, "/api/nodes"]
[stale_nodes, URI("https://#{host}#{path}")]
when [restricted_domain, "/api/nodes"]
[remote_nodes, URI("https://#{host}#{path}")]
when [unreachable_domain, "/api/nodes"]
[remote_nodes, URI("https://#{host}#{path}")]
when [unreachable_domain, "/api/instances"]
[nil, ["connection refused"]]
else
[nil, []]
end
end
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
domains = db.execute("SELECT domain FROM instances ORDER BY domain").flatten
expect(domains).to include(domain, unreachable_domain)
expect(domains).not_to include(stale_domain, private_domain, "invalid.mesh", offline_domain, restricted_domain)
expect(domains.count { |value| value == domain }).to eq(1)
end
expect(warning_calls).to include(
[
["Failed to load remote federation instances"],
hash_including(context: "federation.instances", domain: unreachable_domain),
],
)
expect(warning_calls).to include(
[
["Discarded remote instance entry"],
hash_including(domain: stale_domain, reason: "node data is stale"),
],
)
expect(warning_calls).to include(
[
["Failed to persist remote instance"],
hash_including(domain: restricted_domain, error_class: "ArgumentError"),
],
)
end
it "accepts signatures when the optional isPrivate field is omitted" do
unsigned_attributes = instance_attributes.merge(is_private: nil)
unsigned_payload_json = canonical_instance_payload(unsigned_attributes)
unsigned_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, unsigned_payload_json),
)
payload_without_private = instance_payload.reject { |key, _| key == "isPrivate" }
payload_without_private["signature"] = unsigned_signature
post "/api/instances", payload_without_private.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT * FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["is_private"]).to eq(0)
end
end
it "replaces an existing record when the domain is reused" do
with_db do |db|
db.execute(
<<~SQL,
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
[
"legacy-id",
domain,
"legacy-pubkey",
"Legacy Instance",
"0.9.0",
nil,
nil,
nil,
nil,
last_update_time - 100,
0,
"legacy-signature",
],
)
end
debug_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:debug_log).and_wrap_original do |method, *args, **kwargs|
debug_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
ids = db.execute("SELECT id FROM instances WHERE domain = ?", [domain]).flatten
expect(ids).to eq([instance_attributes[:id]])
end
expect(debug_calls).to include(
[
["Removed conflicting instance by domain"],
hash_including(
context: "federation.instances",
domain: domain,
replaced_id: "legacy-id",
incoming_id: instance_attributes[:id],
),
],
)
end
it "normalises stored domains to lowercase" do
uppercase_payload = instance_payload.merge("domain" => "Mesh.Example")
post "/api/instances", uppercase_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value("SELECT domain FROM instances WHERE id = ?", [instance_attributes[:id]])
expect(stored).to eq(domain)
end
end
it "rejects registrations missing last_heard data" do
missing_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{ "node_id" => "remote-#{index}", "first_heard" => Time.now.to_i - index }
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case path
when "/.well-known/potato-mesh"
[well_known_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[missing_nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "missing last_heard data")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: domain,
reason: "missing last_heard data",
),
],
)
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 "returns 404 and never attempts to process the registration" do
# A disabled instance must reject the announcement before touching the
# network or the database — otherwise an attacker could still force
# outbound federation fetches and DB writes against a PRIVATE=1 /
# FEDERATION=0 deployment simply by POSTing a signed announcement.
expect_any_instance_of(Sinatra::Application).not_to receive(:fetch_instance_json)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(404)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM instances WHERE id = ?", [instance_attributes[:id]])
expect(count).to eq(0)
end
end
end
context "when private mode is enabled" do
it "returns 404 regardless of the FEDERATION setting" do
# federation_enabled? returns false whenever private mode is on,
# independent of FEDERATION; the route guard must honour that too. Stub
# Config.private_mode_enabled? (the suite's convention, e.g. the node
# visibility specs) rather than toggling ENV["PRIVATE"] — the top-level
# `before` hook deletes ENV["PRIVATE"] before each example, and stubbing
# also avoids any cross-spec ENV leak.
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(404)
end
end
end
describe "GET /api/instances" do
let(:remote_key) { OpenSSL::PKey::RSA.new(2048) }
it "returns the self instance record" do
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
self_entry = payload.find { |entry| entry["id"] == SELF_INSTANCE_ID }
expect(self_entry).not_to be_nil
expect(self_entry["domain"]).not_to be_nil
expect(self_entry["is_private"]).to eq(false)
expect(self_entry["signature"]).not_to be_nil
expect(self_entry["signature_version"]).to eq(2)
# FS-A4: wire is snake_case only — no camelCase keys leak through.
expect(self_entry).not_to have_key("isPrivate")
expect(self_entry).not_to have_key("lastUpdateTime")
expect(self_entry).not_to have_key("nodesCount")
end
it "exposes validated instance records from fixture data" do
clear_database
sample_size = 3
import_instances_fixture(limit: sample_size)
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
non_self_entries = payload.reject { |entry| entry["id"] == SELF_INSTANCE_ID }
expect(non_self_entries.size).to eq(sample_size)
sample = instances_fixture.first
record = non_self_entries.find { |entry| entry["id"] == sample["id"] }
expect(record).not_to be_nil
expect(record["domain"]).to eq(sample["domain"])
expect(record["name"]).to eq(sample["name"])
end
it "includes previously stored remote registrations" do
remote_attributes = {
id: "remote-instance-1",
domain: "remote.example",
pubkey: remote_key.public_key.export,
name: "Remote Mesh",
version: "9.8.7",
channel: "#Remote",
frequency: "915MHz",
latitude: 51.5,
longitude: -0.1,
last_update_time: Time.now.to_i,
is_private: false,
}
remote_signature_payload = canonical_instance_payload(remote_attributes)
remote_signature = Base64.strict_encode64(
remote_key.sign(OpenSSL::Digest::SHA256.new, remote_signature_payload),
)
with_db do |db|
upsert_instance_record(db, remote_attributes, remote_signature)
end
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
remote_entry = payload.find { |entry| entry["id"] == remote_attributes[:id] }
expect(remote_entry).not_to be_nil
expect(remote_entry["domain"]).to eq("remote.example")
expect(remote_entry["is_private"]).to eq(false)
expect(remote_entry["signature"]).to eq(remote_signature)
end
it "skips malformed rows without failing" do
with_db do |db|
sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
db.execute(
sql,
[
"broken-instance",
"invalid domain name",
remote_key.public_key.export,
"Broken",
"0.0.0",
nil,
nil,
"not-a-number",
nil,
"not-a-timestamp",
"not-a-bool",
nil,
],
)
end
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
broken_entry = payload.find { |entry| entry["id"] == "broken-instance" }
expect(broken_entry).to be_nil
expect(payload).not_to be_empty
end
it "deduplicates records by domain keeping the newest entry" do
newer_time = Time.now.to_i
older_time = newer_time - 60
with_db do |db|
insert_sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
db.execute(
insert_sql,
[
"duplicate-old",
"duplicate.example ",
remote_key.public_key.export,
"Duplicate Old",
"1.0.0",
nil,
nil,
nil,
nil,
older_time,
0,
"sig-old",
],
)
db.execute(
insert_sql,
[
"duplicate-new",
"Duplicate.Example",
remote_key.public_key.export,
"Duplicate New",
"2.0.0",
nil,
nil,
nil,
nil,
newer_time,
0,
"sig-new",
],
)
end
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
duplicate_entries = payload.select { |entry| entry["domain"] == "duplicate.example" }
expect(duplicate_entries.size).to eq(1)
expect(duplicate_entries.first["id"]).to eq("duplicate-new")
with_db(readonly: true) do |db|
domains = db.execute(
"SELECT domain FROM instances WHERE domain LIKE ? ORDER BY domain",
["duplicate.example%"],
).flatten
expect(domains).to eq(["duplicate.example"])
end
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 "returns 404" do
get "/api/instances"
expect(last_response.status).to eq(404)
end
end
describe "response caching" do
it "serves a stable etag and returns 304 for matching If-None-Match" do
get "/api/instances"
expect(last_response).to be_ok
first_etag = last_response.headers["ETag"]
expect(first_etag).not_to be_nil
header "If-None-Match", first_etag
get "/api/instances"
expect(last_response.status).to eq(304)
end
it "returns the same response body for repeat requests within the TTL" do
# The bodies must be byte-identical: a fresh self-record sign would
# produce a different last_update_time / signature, so consistency
# confirms the cached entry is being reused.
get "/api/instances"
expect(last_response).to be_ok
first_body = last_response.body
first_etag = last_response.headers["ETag"]
get "/api/instances"
expect(last_response).to be_ok
expect(last_response.body).to eq(first_body)
expect(last_response.headers["ETag"]).to eq(first_etag)
end
it "invalidates the cache when a new peer registers" do
get "/api/instances"
expect(last_response).to be_ok
baseline_size = JSON.parse(last_response.body).length
# Build a valid peer registration that POST /api/instances accepts.
peer_key = OpenSSL::PKey::RSA.new(2048)
peer_attributes = {
id: "cache-peer",
domain: "cache-peer.example",
pubkey: peer_key.public_key.export,
name: "Cache Peer",
version: "1.0.0",
channel: "#peer",
frequency: "868MHz",
latitude: 50.0,
longitude: 8.0,
last_update_time: Time.now.to_i,
is_private: false,
}
peer_signature = Base64.strict_encode64(
peer_key.sign(
OpenSSL::Digest::SHA256.new,
canonical_instance_payload(peer_attributes),
),
)
with_db do |db|
upsert_instance_record(db, peer_attributes, peer_signature)
end
# The cache key is unaware of the direct DB insert, so explicitly
# invalidate to mirror what POST /api/instances does in production.
PotatoMesh::App::ApiCache.invalidate_prefix("api:instances:")
get "/api/instances"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(baseline_size + 1)
expect(payload.map { |row| row["id"] }).to include("cache-peer")
end
end
end
describe "POST /api/nodes" do
it "imports nodes from fixture data into the database" do
import_nodes_fixture
expected_nodes = nodes_fixture.map do |node|
[node["node_id"], expected_node_row(node)]
end.to_h
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(<<~SQL)
SELECT node_id, short_name, long_name, hw_model, role, snr,
battery_level, voltage, last_heard, first_heard,
uptime_seconds, channel_utilization, air_util_tx,
position_time, location_source, precision_bits,
latitude, longitude, altitude, lora_freq, modem_preset
FROM nodes
ORDER BY node_id
SQL
expect(rows.size).to eq(expected_nodes.size)
rows.each do |row|
expected = expected_nodes.fetch(row["node_id"])
expect(row["short_name"]).to eq(expected["short_name"])
expect(row["long_name"]).to eq(expected["long_name"])
expect(row["hw_model"]).to eq(expected["hw_model"])
expect(row["role"]).to eq(expected["role"])
expect_same_value(row["snr"], expected["snr"])
expect_same_value(row["battery_level"], expected["battery_level"])
expect_same_value(row["voltage"], expected["voltage"])
expect(row["last_heard"]).to eq(expected["last_heard"])
expect(row["first_heard"]).to eq(expected["first_heard"])
expect_same_value(row["uptime_seconds"], expected["uptime_seconds"])
expect_same_value(row["channel_utilization"], expected["channel_utilization"])
expect_same_value(row["air_util_tx"], expected["air_util_tx"])
expect_same_value(row["position_time"], expected["position_time"])
expect(row["location_source"]).to eq(expected["location_source"])
expect_same_value(row["precision_bits"], expected["precision_bits"])
expect_same_value(row["latitude"], expected["latitude"])
expect_same_value(row["longitude"], expected["longitude"])
expect_same_value(row["altitude"], expected["altitude"])
expect_same_value(row["lora_freq"], expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
end
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/nodes", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "updates timestamps when the payload omits lastHeard" do
node_id = "!spectime01"
payload = {
node_id => {
"user" => { "shortName" => "Spec Time" },
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
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(reference_time.to_i)
expect(row["first_heard"]).to eq(reference_time.to_i)
end
end
it "preserves the original first_heard when updating nodes" do
node_id = "!spectime02"
initial_first = reference_time.to_i - 600
initial_last = reference_time.to_i - 300
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES (?,?,?)",
[node_id, initial_last, initial_first],
)
end
payload = {
node_id => {
"user" => { "shortName" => "Spec Update" },
"lastHeard" => reference_time.to_i,
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
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(reference_time.to_i)
expect(row["first_heard"]).to eq(initial_first)
end
end
it "preserves existing coordinates when a subsequent upsert has no position data" do
node_id = "!f7e74be6"
first_heard = reference_time.to_i - 3600
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard, latitude, longitude, altitude, position_time) VALUES (?,?,?,?,?,?,?)",
[node_id, first_heard, first_heard, 53.8673152, 27.5283968, 271, first_heard],
)
end
nodeinfo_payload = {
node_id => {
"user" => { "shortName" => "MUTE", "longName" => "ClientMute", "hwModel" => "HELTEC_MESH_POCKET", "role" => "CLIENT_MUTE" },
"lastHeard" => reference_time.to_i,
},
}
post "/api/nodes", nodeinfo_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT latitude, longitude, altitude, position_time FROM nodes WHERE node_id = ?",
[node_id],
)
expect(row["latitude"]).to be_within(1e-6).of(53.8673152)
expect(row["longitude"]).to be_within(1e-6).of(27.5283968)
expect(row["altitude"]).to be_within(0.01).of(271)
expect(row["position_time"]).to eq(first_heard)
end
end
it "returns 400 when more than 1000 nodes are provided" do
payload = (0..1000).each_with_object({}) do |i, acc|
acc["node-#{i}"] = {}
end
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many nodes")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM nodes")
expect(count).to eq(0)
end
end
it "returns 413 when the request body exceeds the configured byte limit" do
limit = 64
allow(PotatoMesh::Config).to receive(:max_json_body_bytes).and_return(limit)
payload = { "huge-node" => { "user" => { "shortName" => "A" * (limit + 50) } } }.to_json
expect(payload.bytesize).to be > limit
post "/api/nodes", payload, auth_headers
expect(last_response.status).to eq(413)
expect(JSON.parse(last_response.body)).to eq("error" => "payload too large")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM nodes")
expect(count).to eq(0)
end
end
it "treats SQL-looking node identifiers as plain data" do
malicious_id = "spec-node'); DROP TABLE nodes;--"
payload = {
malicious_id => {
"user" => { "shortName" => "Spec Attack" },
"lastHeard" => reference_time.to_i,
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, short_name FROM nodes WHERE node_id = ?",
[malicious_id],
)
expect(row["node_id"]).to eq(malicious_id)
expect(row["short_name"]).to eq("Spec Attack")
tables = db.get_first_value(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='nodes'",
)
expect(tables).to eq(1)
end
end
it "retries node upserts when the database reports it is locked" do
node = nodes_fixture.first
payload = { node["node_id"] => build_node_payload(node) }
call_count = 0
allow_any_instance_of(SQLite3::Database).to receive(:execute).and_wrap_original do |method, sql, *args|
if sql.include?("INSERT INTO nodes")
call_count += 1
raise SQLite3::BusyException, "database is locked" if call_count == 1
end
method.call(sql, *args)
end
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
expect(call_count).to be >= 2
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = ?", [node["node_id"]])
expect(count).to eq(1)
last_heard = db.get_first_value(SELECT_NODE_LAST_HEARD_SQL, [node["node_id"]])
expect(last_heard).to eq(expected_last_heard(node))
end
end
describe "generic fallback long name protection" do
# node_id !deadbeef → short_id BEEF; generic names are "<Label> BEEF"
let(:node_id) { "!deadbeef" }
def seed_node(long_name: nil)
with_db do |db|
if long_name
db.execute(
"INSERT INTO nodes(node_id, long_name, last_heard) VALUES (?,?,?)",
[node_id, long_name, reference_time.to_i - 3600],
)
else
db.execute(
"INSERT INTO nodes(node_id, last_heard) VALUES (?,?)",
[node_id, reference_time.to_i - 3600],
)
end
end
end
def post_long_name(long_name, ingestor: nil)
payload = { node_id => { "user" => { "longName" => long_name }, "lastHeard" => reference_time.to_i } }
payload["ingestor"] = ingestor if ingestor
post "/api/nodes", payload.to_json, auth_headers
end
def stored_long_name
with_db(readonly: true) do |db|
return db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [node_id])
end
end
it "does not overwrite a real name with a meshtastic generic fallback" do
seed_node(long_name: "Peter's Node")
post_long_name("Meshtastic BEEF")
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
it "writes a generic fallback when no name is on record" do
seed_node
post_long_name("Meshtastic BEEF")
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Meshtastic BEEF")
end
it "overwrites a generic fallback with a real name" do
seed_node(long_name: "Meshtastic BEEF")
post_long_name("Peter's Node")
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
it "does not overwrite a real name with a meshcore generic fallback" do
ingestor_id = "!aabbccdd"
post "/api/ingestors",
{ node_id: ingestor_id, start_time: reference_time.to_i - 60,
last_seen_time: reference_time.to_i, version: "1.0.0", protocol: "meshcore" }.to_json,
auth_headers
seed_node(long_name: "Peter's Node")
post_long_name("Meshcore BEEF", ingestor: ingestor_id)
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
end
end
describe "#ensure_unknown_node" do
it "creates a hidden placeholder with timestamps for chat notifications" do
with_db do |db|
created = ensure_unknown_node(db, "!1234abcd", nil, heard_time: reference_time.to_i)
expect(created).to be_truthy
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
<<~SQL,
SELECT short_name, long_name, role, protocol, last_heard, first_heard
FROM nodes
WHERE node_id = ?
SQL
["!1234abcd"],
)
expect(row["short_name"]).to eq("ABCD")
expect(row["long_name"]).to eq("Meshtastic ABCD")
expect(row["role"]).to eq("CLIENT_HIDDEN")
expect(row["protocol"]).to eq("meshtastic")
expect(row["last_heard"]).to eq(reference_time.to_i)
expect(row["first_heard"]).to eq(reference_time.to_i)
end
end
it "stores meshcore protocol and COMPANION role for meshcore nodes" do
with_db do |db|
created = ensure_unknown_node(db, "!abcd1234", nil, heard_time: reference_time.to_i, protocol: "meshcore")
expect(created).to be_truthy
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
<<~SQL,
SELECT short_name, long_name, role, protocol
FROM nodes
WHERE node_id = ?
SQL
["!abcd1234"],
)
expect(row["short_name"]).to eq("1234")
expect(row["long_name"]).to eq("Meshcore 1234")
expect(row["role"]).to eq("COMPANION")
expect(row["protocol"]).to eq("meshcore")
end
end
it "defaults to meshtastic protocol and CLIENT_HIDDEN role" do
with_db do |db|
created = ensure_unknown_node(db, "!beef0000", nil)
expect(created).to be_truthy
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
<<~SQL,
SELECT role, protocol
FROM nodes
WHERE node_id = ?
SQL
["!beef0000"],
)
expect(row["role"]).to eq("CLIENT_HIDDEN")
expect(row["protocol"]).to eq("meshtastic")
end
end
it "falls back to CLIENT_HIDDEN for an unknown protocol" do
with_db do |db|
created = ensure_unknown_node(db, "!cafe9999", nil, protocol: "reticulum")
expect(created).to be_truthy
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
<<~SQL,
SELECT role, protocol, long_name
FROM nodes
WHERE node_id = ?
SQL
["!cafe9999"],
)
expect(row["role"]).to eq("CLIENT_HIDDEN")
expect(row["protocol"]).to eq("reticulum")
expect(row["long_name"]).to eq("Reticulum 9999")
end
end
it "leaves timestamps nil when no receive time is provided" do
with_db do |db|
created = ensure_unknown_node(db, "!1111beef", nil)
expect(created).to be_truthy
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
<<~SQL,
SELECT last_heard, first_heard
FROM nodes
WHERE node_id = ?
SQL
["!1111beef"],
)
expect(row["last_heard"]).to be_nil
expect(row["first_heard"]).to be_nil
end
end
it "returns false when the node already exists" do
with_db do |db|
expect(ensure_unknown_node(db, "!0000c0de", nil)).to be_truthy
expect(ensure_unknown_node(db, "!0000c0de", nil)).to be_falsey
end
end
end
describe "POST /api/messages" do
SELECT_MESSAGE_ENCRYPTED_SQL = "SELECT encrypted FROM messages WHERE id = ?".freeze
SELECT_NEIGHBOR_COUNT_BY_NODE_SQL = "SELECT COUNT(*) FROM neighbors WHERE node_id = ?".freeze
NODE_INFO_LONG_NAME = "Node Info".freeze
FIRST_MESSAGE_INGESTOR_ID = "!1111aaaa".freeze
SHARED_TEST_INGESTOR_ID = "!aaaa1111".freeze
DEADBEEF_NODE_ID = "!deadbeef".freeze
NEIGHBOR_EMPTY_UPDATE_ROOT_ID = "!cafed00d".freeze
NEIGHBOR_ROOT_ID = "!1a2b3c01".freeze
NEIGHBOR_PRIMARY_ID = "!1a2b3c02".freeze
NEIGHBOR_SNR_CLEAR_ROOT_ID = "!1a2b3c10".freeze
NEIGHBOR_SNR_CLEAR_PEER_ID = "!1a2b3c11".freeze
NEIGHBOR_CHUNK_ROOT_ID = "!1a2b3c30".freeze
def post_twice_for_ingestor(endpoint, first_payload, second_payload)
post endpoint, first_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
post endpoint, second_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
end
it "persists messages from fixture data" do
import_nodes_fixture
import_messages_fixture
expected_messages = messages_fixture.map do |message|
[message["id"], message.reject { |key, _| key == "node" }]
end.to_h
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(<<~SQL)
SELECT id, rx_time, rx_iso, from_id, to_id, channel,
portnum, text, snr, rssi, hop_limit,
lora_freq, modem_preset, channel_name,
reply_id, emoji
FROM messages
ORDER BY id
SQL
expect(rows.size).to eq(expected_messages.size)
rows.each do |row|
expected = expected_messages.fetch(row["id"])
expect(row["rx_time"]).to eq(expected["rx_time"])
expect(row["rx_iso"]).to eq(expected["rx_iso"])
expect(row["from_id"]).to eq(expected["from_id"])
expect(row["to_id"]).to eq(expected["to_id"])
expect(row["channel"]).to eq(expected["channel"])
expect(row["portnum"]).to eq(expected["portnum"])
expect(row["text"]).to eq(expected["text"])
expect_same_value(row["snr"], expected["snr"])
expect(row["rssi"]).to eq(expected["rssi"])
expect(row["hop_limit"]).to eq(expected["hop_limit"])
expect(row["lora_freq"]).to eq(expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
expect(row["channel_name"]).to eq(expected["channel_name"])
expect(row["reply_id"]).to eq(expected["reply_id"])
expect(row["emoji"]).to eq(expected["emoji"])
end
end
end
it "persists reply metadata and emoji reactions" do
parent_payload = {
"id" => 42,
"rx_time" => reference_time.to_i - 10,
"from_id" => "!parent",
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "source message",
}
reaction_payload = {
"id" => 108,
"rx_time" => reference_time.to_i,
"from_id" => "!reactor",
"channel" => 0,
"portnum" => "REACTION_APP",
"reply_id" => parent_payload["id"],
"emoji" => " 🔥 ",
}
post "/api/messages", parent_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/messages", reaction_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT reply_id, emoji FROM messages WHERE id = ?", [reaction_payload["id"]])
expect(row["reply_id"]).to eq(parent_payload["id"])
expect(row["emoji"]).to eq("🔥")
end
get "/api/messages"
expect(last_response).to be_ok
body = JSON.parse(last_response.body)
reaction_row = body.find { |entry| entry["id"] == reaction_payload["id"] }
expect(reaction_row).not_to be_nil
expect(reaction_row["reply_id"]).to eq(parent_payload["id"])
expect(reaction_row["emoji"]).to eq("🔥")
end
it "stores message ingestor and preserves the first reporter" do
first_payload = {
"id" => 77_001,
"rx_time" => reference_time.to_i - 10,
"from_id" => "!ingmsg01",
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "first reporter",
"ingestor" => FIRST_MESSAGE_INGESTOR_ID,
}
second_payload = first_payload.merge(
"text" => "updated text",
"ingestor" => "!2222bbbb",
)
post_twice_for_ingestor("/api/messages", first_payload, second_payload)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT text, ingestor FROM messages WHERE id = ?", [first_payload["id"]])
expect(row["text"]).to eq("updated text")
expect(row["ingestor"]).to eq(FIRST_MESSAGE_INGESTOR_ID)
end
get "/api/messages?limit=10"
expect(last_response).to be_ok
row = JSON.parse(last_response.body).find { |entry| entry["id"] == first_payload["id"] }
expect(row["ingestor"]).to eq(FIRST_MESSAGE_INGESTOR_ID)
end
it "creates hidden nodes for unknown message senders" do
payload = {
"id" => 9_999,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.iso8601,
"from_id" => "!feedf00d",
"to_id" => "^all",
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "Spec placeholder message",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, num, short_name, long_name, role, last_heard, first_heard FROM nodes WHERE node_id = ?",
["!feedf00d"],
)
expect(row).not_to be_nil
expect(row["node_id"]).to eq("!feedf00d")
expect(row["num"]).to eq(0xfeedf00d)
expect(row["short_name"]).to eq("F00D")
expect(row["long_name"]).to eq("Meshtastic F00D")
expect(row["role"]).to eq("CLIENT_HIDDEN")
expect(row["last_heard"]).to eq(payload["rx_time"])
expect(row["first_heard"]).to eq(payload["rx_time"])
end
end
it "ignores broadcast identifiers when creating placeholders" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
payload = {
"id" => 10_002,
"rx_time" => reference_time.to_i,
"from_id" => "!ffffffff",
"channel" => 0,
"text" => "broadcast",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
expect(count).to eq(0)
end
end
it "creates hidden nodes for unseen message participants" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
payload = {
"id" => 10_001,
"rx_time" => reference_time.to_i,
"from_id" => "!cafef00d",
"to_id" => DEADBEEF_NODE_ID,
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "Spec participant placeholder",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(
<<~SQL,
SELECT node_id, num, short_name, long_name, role, last_heard, first_heard
FROM nodes
WHERE node_id IN ("!cafef00d", "#{DEADBEEF_NODE_ID}")
ORDER BY node_id
SQL
)
expect(rows.map { |row| row["node_id"] }).to contain_exactly("!cafef00d", DEADBEEF_NODE_ID)
rows.each do |row|
expect(row["num"]).to be_an(Integer)
expect(row["role"]).to eq("CLIENT_HIDDEN")
expect(row["short_name"]).to eq(row["node_id"][-4, 4].upcase)
expect(row["long_name"]).to eq("Meshtastic #{row["short_name"]}")
expect(row["last_heard"]).to eq(reference_time.to_i)
expect(row["first_heard"]).to eq(reference_time.to_i)
end
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/messages", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "rejects message payloads that are larger than the configured byte limit" do
limit = 64
allow(PotatoMesh::Config).to receive(:max_json_body_bytes).and_return(limit)
payload = [{ "id" => "m1", "text" => "A" * (limit + 50) }].to_json
expect(payload.bytesize).to be > limit
post "/api/messages", payload, auth_headers
expect(last_response.status).to eq(413)
expect(JSON.parse(last_response.body)).to eq("error" => "payload too large")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM messages")
expect(count).to eq(0)
end
end
describe "POST /api/positions" do
it "stores position packets and updates node metadata" do
node_id = "!specpos01"
node_num = 0x1234_5678
initial_last_heard = reference_time.to_i - 600
node_payload = {
node_id => {
"num" => node_num,
"user" => { "shortName" => "SpecPos" },
"lastHeard" => initial_last_heard,
"position" => {
"time" => initial_last_heard - 60,
"latitude" => 52.0,
"longitude" => 13.0,
},
},
}
post "/api/nodes", node_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
rx_time = reference_time.to_i - 120
position_time = rx_time - 30
raw_payload = { "time" => position_time, "latitude_i" => (52.5 * 1e7).to_i }
position_payload = {
"id" => 9_001,
"node_id" => node_id,
"node_num" => node_num,
"rx_time" => rx_time,
"rx_iso" => Time.at(rx_time).utc.iso8601,
"to_id" => "^all",
"latitude" => 52.5,
"longitude" => 13.4,
"altitude" => 42.0,
"position_time" => position_time,
"location_source" => "LOC_INTERNAL",
"precision_bits" => 15,
"sats_in_view" => 6,
"pdop" => 2.5,
"ground_speed" => 3.2,
"ground_track" => 180.0,
"snr" => -8.5,
"rssi" => -90,
"hop_limit" => 3,
"bitfield" => 1,
"payload_b64" => "AQI=",
"raw" => raw_payload,
}
post "/api/positions", position_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT * FROM positions WHERE id = ?", [9_001])
expect(row["node_id"]).to eq(node_id)
expect(row["node_num"]).to eq(node_num)
expect(row["rx_time"]).to eq(rx_time)
expect(row["rx_iso"]).to eq(Time.at(rx_time).utc.iso8601)
expect(row["position_time"]).to eq(position_time)
expect_same_value(row["latitude"], 52.5)
expect_same_value(row["longitude"], 13.4)
expect_same_value(row["altitude"], 42.0)
expect(row["location_source"]).to eq("LOC_INTERNAL")
expect(row["precision_bits"]).to eq(15)
expect(row["sats_in_view"]).to eq(6)
expect_same_value(row["pdop"], 2.5)
expect_same_value(row["ground_speed"], 3.2)
expect_same_value(row["ground_track"], 180.0)
expect_same_value(row["snr"], -8.5)
expect(row["rssi"]).to eq(-90)
expect(row["hop_limit"]).to eq(3)
expect(row["bitfield"]).to eq(1)
expect(row["payload_b64"]).to eq("AQI=")
end
with_db(readonly: true) do |db|
db.results_as_hash = true
node_row = db.get_first_row(
"SELECT last_heard, position_time, latitude, longitude, altitude, location_source, precision_bits, snr FROM nodes WHERE node_id = ?",
[node_id],
)
expect(node_row["last_heard"]).to eq(rx_time)
expect(node_row["position_time"]).to eq(position_time)
expect_same_value(node_row["latitude"], 52.5)
expect_same_value(node_row["longitude"], 13.4)
expect_same_value(node_row["altitude"], 42.0)
expect(node_row["location_source"]).to eq("LOC_INTERNAL")
expect(node_row["precision_bits"]).to eq(15)
expect_same_value(node_row["snr"], -8.5)
end
end
it "creates node records when none exist" do
node_id = "!specnew01"
node_num = 0xfeed_cafe
rx_time = reference_time.to_i - 60
position_time = rx_time - 10
payload = {
"id" => 9_002,
"node_id" => node_id,
"node_num" => node_num,
"rx_time" => rx_time,
"rx_iso" => Time.at(rx_time).utc.iso8601,
"latitude" => 52.1,
"longitude" => 13.1,
"altitude" => 33.0,
"position_time" => position_time,
"location_source" => "LOC_EXTERNAL",
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
node_row = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [node_id])
expect(node_row).not_to be_nil
expect(node_row["num"]).to eq(node_num)
expect(node_row["last_heard"]).to eq(rx_time)
expect(node_row["first_heard"]).to eq(rx_time)
expect(node_row["position_time"]).to eq(position_time)
expect_same_value(node_row["latitude"], 52.1)
expect_same_value(node_row["longitude"], 13.1)
expect_same_value(node_row["altitude"], 33.0)
expect(node_row["location_source"]).to eq("LOC_EXTERNAL")
end
end
it "creates hidden nodes for unknown position senders" do
payload = {
"id" => 42,
"node_id" => "!0badc0de",
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.iso8601,
"latitude" => 52.1,
"longitude" => 13.1,
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, num, short_name, long_name, role FROM nodes WHERE node_id = ?",
["!0badc0de"],
)
expect(row).not_to be_nil
expect(row["node_id"]).to eq("!0badc0de")
expect(row["num"]).to eq(0x0badc0de)
expect(row["short_name"]).to eq("C0DE")
expect(row["long_name"]).to eq("Meshtastic C0DE")
expect(row["role"]).to eq("CLIENT_HIDDEN")
end
end
it "stores position ingestor and preserves the first reporter" do
first_payload = {
"id" => 19_001,
"node_id" => "!ingpos01",
"rx_time" => reference_time.to_i - 80,
"latitude" => 52.1,
"longitude" => 13.2,
"ingestor" => SHARED_TEST_INGESTOR_ID,
}
second_payload = first_payload.merge(
"latitude" => 53.3,
"ingestor" => "!bbbb2222",
)
post_twice_for_ingestor("/api/positions", first_payload, second_payload)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT latitude, ingestor FROM positions WHERE id = ?", [first_payload["id"]])
expect_same_value(row["latitude"], 53.3)
expect(row["ingestor"]).to eq(SHARED_TEST_INGESTOR_ID)
end
end
it "fills first_heard when updating an existing node without one" do
node_id = "!specposfh"
rx_time = reference_time.to_i - 90
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES (?,?,?)",
[node_id, nil, nil],
)
end
payload = {
"id" => 51,
"node_id" => node_id,
"rx_time" => rx_time,
"latitude" => 51.5,
"longitude" => -0.12,
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
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)
end
end
it "stores position fixture samples with detailed metadata" do
clear_database
sample_count = 5
import_positions_fixture(limit: sample_count)
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(
"SELECT id, node_id, latitude, longitude, altitude, location_source FROM positions ORDER BY id",
)
expect(rows.size).to eq(sample_count)
positions_fixture.first(sample_count).each do |expected|
row = rows.find { |entry| entry["id"] == expected["id"] }
expect(row).not_to be_nil
expect(row["node_id"]).to eq(expected["node_id"])
expect_same_value(row["latitude"], expected["latitude"])
expect_same_value(row["longitude"], expected["longitude"])
expect_same_value(row["altitude"], expected["altitude"])
expect(row["location_source"]).to eq(expected["location_source"])
end
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/positions", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "returns 400 when more than 1000 positions are provided" do
payload = Array.new(1001) { |i| { "id" => i + 1, "rx_time" => reference_time.to_i - i } }
post "/api/positions", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many positions")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM positions")
expect(count).to eq(0)
end
end
end
describe "POST /api/waypoints" do
it "requires a bearer token (C1)" do
post "/api/waypoints", [].to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(403)
end
it "returns 400 when the payload is not valid JSON" do
post "/api/waypoints", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "returns 400 when the payload is neither an Array nor a Hash (IC-A4)" do
post "/api/waypoints", '"pin"', auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid payload")
end
it "returns 400 when more than 1000 waypoints are provided" do
payload = Array.new(1001) { |i| { "id" => i + 1, "rx_time" => reference_time.to_i - i } }
post "/api/waypoints", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many waypoints")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM waypoints")
expect(count).to eq(0)
end
end
it "stores waypoint packets, canonicalises ids, and advances the author node (W1/W5)" do
rx_time = reference_time.to_i - 120
expire = reference_time.to_i + 3600
payload = {
"id" => 41_206,
"node_id" => "!3769b133",
"node_num" => 0x3769b133,
"rx_time" => rx_time,
"rx_iso" => Time.at(rx_time).utc.iso8601,
"name" => "Tempelhofer Feld",
"description" => "There is no other place in Berlin to see further. : )",
"icon" => 0x2708,
"latitude" => 52.4751642,
"longitude" => 13.4029586,
"expire" => expire,
"locked_to" => 0x3769b133,
"snr" => -8.5,
"rssi" => -90,
"hop_limit" => 3,
"payload_b64" => "AQI=",
"ingestor" => "!feedf00d",
}
post "/api/waypoints", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT * FROM waypoints WHERE id = ?", [41_206])
expect(row["node_id"]).to eq("!3769b133")
expect(row["node_num"]).to eq(0x3769b133)
expect(row["rx_time"]).to eq(rx_time)
expect(row["name"]).to eq("Tempelhofer Feld")
expect(row["description"]).to include("no other place")
expect(row["icon"]).to eq(0x2708)
expect_same_value(row["latitude"], 52.4751642)
expect_same_value(row["longitude"], 13.4029586)
expect(row["expire"]).to eq(expire)
expect(row["locked_to"]).to eq("!3769b133")
expect_same_value(row["snr"], -8.5)
expect(row["rssi"]).to eq(-90)
expect(row["hop_limit"]).to eq(3)
expect(row["payload_b64"]).to eq("AQI=")
expect(row["protocol"]).to eq("meshtastic")
node_row = db.get_first_row("SELECT last_heard FROM nodes WHERE node_id = ?", ["!3769b133"])
expect(node_row["last_heard"]).to eq(rx_time)
end
end
it "upserts a re-broadcast of the same waypoint id as the full new state (W5)" do
rx_time = reference_time.to_i - 300
first = {
"id" => 41_207,
"node_id" => "!3769b133",
"rx_time" => rx_time,
"name" => "Abgedreht Bar",
"description" => "Was wollen wir trinken",
"latitude" => 52.5158247,
"longitude" => 13.4520138,
"expire" => reference_time.to_i + 600,
"locked_to" => 0x3769b133,
"snr" => -4.0,
}
second = first.merge(
"rx_time" => rx_time + 60,
"name" => "Abgedreht",
"description" => nil,
"expire" => nil,
"locked_to" => 0,
"snr" => nil,
)
post "/api/waypoints", [first].to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/waypoints", [second].to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute("SELECT * FROM waypoints WHERE id = ?", [41_207])
expect(rows.length).to eq(1)
row = rows.first
expect(row["rx_time"]).to eq(rx_time + 60)
expect(row["name"]).to eq("Abgedreht")
# The newest broadcast is the full new state: cleared fields clear.
expect(row["description"]).to be_nil
expect(row["expire"]).to be_nil
expect(row["locked_to"]).to be_nil
# Radio metadata keeps the last known value when the update omits it.
expect_same_value(row["snr"], -4.0)
end
end
it "ignores an out-of-order stale re-broadcast (cross-ingestor guard, C5)" do
rx_time = reference_time.to_i - 120
fresh = {
"id" => 41_208,
"node_id" => "!3769b133",
"rx_time" => rx_time,
"name" => "Fresh Name",
"latitude" => 52.48,
"longitude" => 13.47,
}
stale = fresh.merge("rx_time" => rx_time - 90, "name" => "Stale Name")
post "/api/waypoints", fresh.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/waypoints", stale.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT * FROM waypoints WHERE id = ?", [41_208])
expect(row["name"]).to eq("Fresh Name")
expect(row["rx_time"]).to eq(rx_time)
end
end
it "keeps same-id waypoints from different protocols as distinct rows (W2)" do
rx_time = reference_time.to_i - 60
base = { "id" => 7, "node_id" => "!11223344", "rx_time" => rx_time, "name" => "Shared id", "latitude" => 52.5, "longitude" => 13.4 }
post "/api/waypoints", base.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/waypoints", base.merge("protocol" => "meshcore", "name" => "Core pin").to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM waypoints WHERE id = 7")
expect(count).to eq(2)
end
end
end
describe "POST /api/neighbors" do
it "stores neighbor tuples and updates node metadata" do
rx_time = reference_time.to_i - 120
neighbor_rx_time = rx_time - 30
payload = {
"node_id" => "!abc123ef",
"node_num" => 0xabc123ef,
"rx_time" => rx_time,
"neighbors" => [
{ "node_id" => "!00ff0011", "snr" => -7.5 },
{ "node_id" => 0x11223344, "snr" => 3.25, "rx_time" => neighbor_rx_time },
],
}
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(
"SELECT node_id, neighbor_id, snr, rx_time FROM neighbors ORDER BY neighbor_id",
)
expect(rows.size).to eq(2)
expect(rows[0]["node_id"]).to eq("!abc123ef")
expect(rows[0]["neighbor_id"]).to eq("!00ff0011")
expect_same_value(rows[0]["snr"], -7.5)
expect(rows[0]["rx_time"]).to eq(rx_time)
expect(rows[1]["node_id"]).to eq("!abc123ef")
expect(rows[1]["neighbor_id"]).to eq("!11223344")
expect_same_value(rows[1]["snr"], 3.25)
expect(rows[1]["rx_time"]).to eq(neighbor_rx_time)
end
get "/api/neighbors"
expect(last_response).to be_ok
neighbors = JSON.parse(last_response.body)
expect(neighbors.map { |row| row["neighbor_id"] }).to contain_exactly("!00ff0011", "!11223344")
expect(neighbors.first).to include("node_id" => "!abc123ef")
expect(neighbors.first["rx_iso"]).to be_a(String)
with_db(readonly: true) do |db|
db.results_as_hash = true
node_rows = db.execute(
"SELECT node_id, last_heard FROM nodes ORDER BY node_id",
)
expect(node_rows.size).to eq(3)
origin = node_rows.find { |row| row["node_id"] == "!abc123ef" }
expect(origin["last_heard"]).to eq(rx_time)
neighbor_one = node_rows.find { |row| row["node_id"] == "!00ff0011" }
expect(neighbor_one["last_heard"]).to eq(rx_time)
neighbor_two = node_rows.find { |row| row["node_id"] == "!11223344" }
expect(neighbor_two["last_heard"]).to eq(neighbor_rx_time)
end
end
it "does not update existing neighbor last_heard from third-party neighbor reports" do
reporter_id = "!abc123ef"
existing_neighbor_id = "!00ff0011"
prior_last_heard = reference_time.to_i - 4 * 60 * 60
rx_time = reference_time.to_i - 60 * 60
neighbor_rx_time = rx_time - 120
with_db do |db|
db.execute(
INSERT_NODE_WITH_LAST_HEARD_SQL,
[reporter_id, 0xabc123ef, prior_last_heard, prior_last_heard],
)
db.execute(
INSERT_NODE_WITH_LAST_HEARD_SQL,
[existing_neighbor_id, 0x00ff0011, prior_last_heard, prior_last_heard],
)
end
payload = {
"node_id" => reporter_id,
"node_num" => 0xabc123ef,
"rx_time" => rx_time,
"neighbors" => [
{ "node_id" => existing_neighbor_id, "snr" => -7.5, "rx_time" => neighbor_rx_time },
],
}
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
reporter_row = db.get_first_row(SELECT_NODE_LAST_HEARD_SQL, [reporter_id])
neighbor_row = db.get_first_row(SELECT_NODE_LAST_HEARD_SQL, [existing_neighbor_id])
expect(reporter_row["last_heard"]).to eq(rx_time)
expect(neighbor_row["last_heard"]).to eq(prior_last_heard)
end
end
it "handles broadcasts with no neighbors" do
rx_time = reference_time.to_i - 60
payload = {
"node_id" => "!cafebabe",
"rx_time" => rx_time,
"neighbors" => [],
}
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM neighbors")
expect(count).to eq(0)
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, last_heard FROM nodes WHERE node_id = ?",
["!cafebabe"],
)
expect(row).not_to be_nil
expect(row["last_heard"]).to eq(rx_time)
end
get "/api/neighbors"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to be_empty
end
it "removes stored neighbors when a later packet contains no neighbors" do
seed_payload = {
"node_id" => NEIGHBOR_EMPTY_UPDATE_ROOT_ID,
"rx_time" => reference_time.to_i - 50,
"neighbors" => [
{ "node_id" => DEADBEEF_NODE_ID, "snr" => -2.0 },
],
"ingestor" => SHARED_TEST_INGESTOR_ID,
}
empty_payload = {
"node_id" => NEIGHBOR_EMPTY_UPDATE_ROOT_ID,
"rx_time" => reference_time.to_i - 10,
"neighbors" => [],
"ingestor" => "!bbbb2222",
}
post "/api/neighbors", seed_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/neighbors", empty_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
remaining = db.get_first_value(SELECT_NEIGHBOR_COUNT_BY_NODE_SQL, [NEIGHBOR_EMPTY_UPDATE_ROOT_ID])
expect(remaining).to eq(0)
end
end
it "stores neighbor ingestor and preserves the first reporter per tuple" do
base = {
"node_id" => NEIGHBOR_ROOT_ID,
"rx_time" => reference_time.to_i - 45,
"neighbors" => [
{ "node_id" => NEIGHBOR_PRIMARY_ID, "snr" => -1.5 },
{ "node_id" => "!1a2b3c03", "snr" => -2.5 },
],
"ingestor" => "!aaaa9999",
}
update = {
"node_id" => NEIGHBOR_ROOT_ID,
"rx_time" => reference_time.to_i - 30,
"neighbors" => [
{ "node_id" => NEIGHBOR_PRIMARY_ID, "snr" => -0.5 },
],
"ingestor" => "!bbbb8888",
}
post_twice_for_ingestor("/api/neighbors", base, update)
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute("SELECT neighbor_id, snr, ingestor FROM neighbors WHERE node_id = ? ORDER BY neighbor_id", [NEIGHBOR_ROOT_ID])
expect(rows.size).to eq(1)
expect(rows.first["neighbor_id"]).to eq(NEIGHBOR_PRIMARY_ID)
expect_same_value(rows.first["snr"], -0.5)
expect(rows.first["ingestor"]).to eq("!aaaa9999")
end
end
it "clears stored neighbor snr when an updated entry omits snr" do
initial = {
"node_id" => NEIGHBOR_SNR_CLEAR_ROOT_ID,
"rx_time" => reference_time.to_i - 40,
"neighbors" => [
{ "node_id" => NEIGHBOR_SNR_CLEAR_PEER_ID, "snr" => -3.25 },
],
}
update = {
"node_id" => NEIGHBOR_SNR_CLEAR_ROOT_ID,
"rx_time" => reference_time.to_i - 20,
"neighbors" => [
{ "node_id" => NEIGHBOR_SNR_CLEAR_PEER_ID },
],
}
post "/api/neighbors", initial.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/neighbors", update.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT snr, rx_time FROM neighbors WHERE node_id = ? AND neighbor_id = ?",
[NEIGHBOR_SNR_CLEAR_ROOT_ID, NEIGHBOR_SNR_CLEAR_PEER_ID],
)
expect(row["snr"]).to be_nil
expect(row["rx_time"]).to eq(update["rx_time"])
end
end
it "removes stale neighbors in chunked deletes" do
initial_neighbors = Array.new(1_100) do |i|
{ "node_id" => format("!%08x", 0x2000_0000 + i), "snr" => -2.0 }
end
initial = {
"node_id" => NEIGHBOR_CHUNK_ROOT_ID,
"rx_time" => reference_time.to_i - 35,
"neighbors" => initial_neighbors,
}
update = {
"node_id" => NEIGHBOR_CHUNK_ROOT_ID,
"rx_time" => reference_time.to_i - 25,
"neighbors" => [
{ "node_id" => "!20000000", "snr" => -1.0 },
],
}
post "/api/neighbors", initial.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/neighbors", update.to_json, auth_headers
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
count = db.get_first_value(
SELECT_NEIGHBOR_COUNT_BY_NODE_SQL,
[NEIGHBOR_CHUNK_ROOT_ID],
)
expect(count).to eq(1)
end
end
it "returns 400 when more than 1000 neighbor packets are provided" do
payload = Array.new(1001) do |i|
{ "node_id" => format("!%08x", i), "rx_time" => reference_time.to_i - i }
end
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many neighbor packets")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM neighbors")
expect(count).to eq(0)
end
end
it "handles large neighbor lists without SQLite bind overflows" do
neighbors = Array.new(1_100) do |i|
{ "node_id" => format("!%08x", 0x1000_0000 + i), "snr" => -1.0 }
end
payload = {
"node_id" => "!1a2b3c20",
"rx_time" => reference_time.to_i - 15,
"neighbors" => neighbors,
}
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
count = db.get_first_value(
SELECT_NEIGHBOR_COUNT_BY_NODE_SQL,
["!1a2b3c20"],
)
expect(count).to eq(1_100)
end
end
end
describe "POST /api/telemetry" do
it "stores telemetry packets and updates node metrics" do
payload = telemetry_fixture
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(
"SELECT * FROM telemetry ORDER BY id",
)
expect(rows.size).to eq(payload.size)
first = rows.find { |row| row["id"] == payload[0]["id"] }
expect(first).not_to be_nil
expect(first["node_id"]).to eq(payload[0]["node_id"])
expect(first["rx_time"]).to eq(payload[0]["rx_time"])
expect_same_value(first["battery_level"], payload[0]["battery_level"])
expect_same_value(first["voltage"], telemetry_metric(payload[0], "voltage"))
expect_same_value(first["channel_utilization"], telemetry_metric(payload[0], "channel_utilization"))
expect_same_value(first["air_util_tx"], telemetry_metric(payload[0], "air_util_tx"))
expect(first["uptime_seconds"]).to eq(telemetry_metric(payload[0], "uptime_seconds"))
expect_same_value(first["current"], payload[0]["current"])
expect_same_value(first["gas_resistance"], payload[0]["gas_resistance"])
expect_same_value(first["iaq"], payload[0]["iaq"])
expect_same_value(first["distance"], payload[0]["distance"])
expect_same_value(first["lux"], payload[0]["lux"])
expect_same_value(first["white_lux"], payload[0]["white_lux"])
expect_same_value(first["ir_lux"], payload[0]["ir_lux"])
expect_same_value(first["uv_lux"], payload[0]["uv_lux"])
expect_same_value(first["wind_direction"], payload[0]["wind_direction"])
expect_same_value(first["wind_speed"], payload[0]["wind_speed"])
expect_same_value(first["wind_gust"], payload[0]["wind_gust"])
expect_same_value(first["wind_lull"], payload[0]["wind_lull"])
expect_same_value(first["weight"], payload[0]["weight"])
expect_same_value(first["radiation"], payload[0]["radiation"])
expect_same_value(first["rainfall_1h"], payload[0]["rainfall_1h"])
expect_same_value(first["rainfall_24h"], payload[0]["rainfall_24h"])
expect_same_value(first["soil_moisture"], payload[0]["soil_moisture"])
expect_same_value(first["soil_temperature"], payload[0]["soil_temperature"])
environment_row = rows.find { |row| row["id"] == payload[1]["id"] }
expect_same_value(environment_row["temperature"], telemetry_metric(payload[1], "temperature"))
expect_same_value(environment_row["relative_humidity"], telemetry_metric(payload[1], "relative_humidity"))
expect_same_value(environment_row["barometric_pressure"], telemetry_metric(payload[1], "barometric_pressure"))
expect_same_value(environment_row["gas_resistance"], telemetry_metric(payload[1], "gas_resistance"))
expect_same_value(environment_row["iaq"], telemetry_metric(payload[1], "iaq"))
expect_same_value(environment_row["distance"], telemetry_metric(payload[1], "distance"))
expect_same_value(environment_row["lux"], telemetry_metric(payload[1], "lux"))
expect_same_value(environment_row["white_lux"], telemetry_metric(payload[1], "white_lux"))
expect_same_value(environment_row["ir_lux"], telemetry_metric(payload[1], "ir_lux"))
expect_same_value(environment_row["uv_lux"], telemetry_metric(payload[1], "uv_lux"))
expect_same_value(environment_row["wind_direction"], telemetry_metric(payload[1], "wind_direction"))
expect_same_value(environment_row["wind_speed"], telemetry_metric(payload[1], "wind_speed"))
expect_same_value(environment_row["wind_gust"], telemetry_metric(payload[1], "wind_gust"))
expect_same_value(environment_row["wind_lull"], telemetry_metric(payload[1], "wind_lull"))
expect_same_value(environment_row["weight"], telemetry_metric(payload[1], "weight"))
expect_same_value(environment_row["radiation"], telemetry_metric(payload[1], "radiation"))
expect_same_value(environment_row["rainfall_1h"], telemetry_metric(payload[1], "rainfall_1h", "rainfall1h"))
expect_same_value(environment_row["rainfall_24h"], telemetry_metric(payload[1], "rainfall_24h", "rainfall24h"))
expect_same_value(environment_row["soil_moisture"], telemetry_metric(payload[1], "soil_moisture"))
expect_same_value(environment_row["soil_temperature"], telemetry_metric(payload[1], "soil_temperature"))
third_row = rows.find { |row| row["id"] == payload[2]["id"] }
expect_same_value(third_row["current"], telemetry_metric(payload[2], "current"))
expect_same_value(third_row["distance"], telemetry_metric(payload[2], "distance"))
expect_same_value(third_row["lux"], telemetry_metric(payload[2], "lux"))
expect_same_value(third_row["wind_direction"], telemetry_metric(payload[2], "wind_direction"))
expect_same_value(third_row["wind_speed"], telemetry_metric(payload[2], "wind_speed"))
expect_same_value(third_row["weight"], telemetry_metric(payload[2], "weight"))
expect_same_value(third_row["rainfall_24h"], telemetry_metric(payload[2], "rainfall_24h", "rainfall24h"))
expect_same_value(third_row["soil_moisture"], telemetry_metric(payload[2], "soil_moisture"))
expect_same_value(third_row["soil_temperature"], telemetry_metric(payload[2], "soil_temperature"))
end
with_db(readonly: true) do |db|
db.results_as_hash = true
metrics_node = db.get_first_row(
"SELECT battery_level, voltage, channel_utilization, air_util_tx, uptime_seconds, last_heard, first_heard FROM nodes WHERE node_id = ?",
[payload[0]["node_id"]],
)
expect_same_value(metrics_node["battery_level"], telemetry_metric(payload[0], "battery_level"))
expect_same_value(metrics_node["voltage"], telemetry_metric(payload[0], "voltage"))
telemetry_for_node = payload.select { |entry| entry["node_id"] == payload[0]["node_id"] }
util_values = telemetry_for_node.filter_map { |entry| telemetry_metric(entry, "channel_utilization") }
air_values = telemetry_for_node.filter_map { |entry| telemetry_metric(entry, "air_util_tx") }
if util_values.any?
expect(metrics_node["channel_utilization"]).to be_between(util_values.min, util_values.max)
else
expect(metrics_node["channel_utilization"]).to be_nil
end
if air_values.any?
expect(metrics_node["air_util_tx"]).to be_between(air_values.min, air_values.max)
else
expect(metrics_node["air_util_tx"]).to be_nil
end
uptime_values = telemetry_for_node.filter_map { |entry| telemetry_metric(entry, "uptime_seconds") }
if uptime_values.any?
expect(metrics_node["uptime_seconds"]).to be_between(uptime_values.min, uptime_values.max)
else
expect(metrics_node["uptime_seconds"]).to be_nil
end
expect(metrics_node["last_heard"]).to eq(payload[0]["rx_time"])
expect(metrics_node["first_heard"]).to eq(payload[0]["rx_time"])
env_node = db.get_first_row(
"SELECT last_heard, battery_level, voltage FROM nodes WHERE node_id = ?",
[payload[1]["node_id"]],
)
env_rx_times = payload.select { |entry| entry["node_id"] == payload[1]["node_id"] }.filter_map do |entry|
entry["rx_time"]
end
latest_rx = env_rx_times.max
expect(env_node["last_heard"]).to eq(latest_rx)
expect_same_value(env_node["battery_level"], telemetry_metric(payload[1], "battery_level"))
expect_same_value(env_node["voltage"], telemetry_metric(payload[1], "voltage"))
local_node = db.get_first_row(
"SELECT battery_level, uptime_seconds, last_heard FROM nodes WHERE node_id = ?",
[payload[2]["node_id"]],
)
expect_same_value(local_node["battery_level"], telemetry_metric(payload[2], "battery_level"))
local_uptime_values = payload.select { |entry| entry["node_id"] == payload[2]["node_id"] }.filter_map do |entry|
telemetry_metric(entry, "uptime_seconds")
end
if local_uptime_values.any?
expect(local_node["uptime_seconds"]).to be_between(local_uptime_values.min, local_uptime_values.max)
else
expect(local_node["uptime_seconds"]).to be_nil
end
local_rx_times = payload.select { |entry| entry["node_id"] == payload[2]["node_id"] }.filter_map do |entry|
entry["rx_time"]
end
if local_rx_times.any?
expect(local_node["last_heard"]).to eq(local_rx_times.max)
else
expect(local_node["last_heard"]).to be_nil
end
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/telemetry", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "stores telemetry ingestor and preserves the first reporter" do
payload = {
"id" => 23_001,
"node_id" => "!ingtel01",
"rx_time" => reference_time.to_i - 70,
"telemetry" => { "deviceMetrics" => { "batteryLevel" => 90 } },
"battery_level" => 90,
"ingestor" => "!1111bbbb",
}
updated = payload.merge("battery_level" => 80, "ingestor" => "!2222cccc")
post_twice_for_ingestor("/api/telemetry", payload, updated)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT battery_level, ingestor FROM telemetry WHERE id = ?", [payload["id"]])
expect_same_value(row["battery_level"], 80.0)
expect(row["ingestor"]).to eq("!1111bbbb")
end
end
it "infers telemetry_type='device' from device_metrics in the payload" do
payload = [
{
"id" => 24_001,
"node_id" => "!teltype01",
"rx_time" => reference_time.to_i - 10,
"device_metrics" => { "battery_level" => 85, "voltage" => 4.1 },
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_001, "device")
end
it "infers telemetry_type='environment' from environment_metrics in the payload" do
payload = [
{
"id" => 24_002,
"node_id" => "!teltype02",
"rx_time" => reference_time.to_i - 20,
"environment_metrics" => { "temperature" => 22.5, "relativeHumidity" => 50 },
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_002, "environment")
end
it "accepts an explicit telemetry_type from the payload" do
payload = [
{
"id" => 24_003,
"node_id" => "!teltype03",
"rx_time" => reference_time.to_i - 30,
"telemetry_type" => "power",
"voltage" => 5.0,
"current" => 0.48,
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_003, "power")
end
it "includes telemetry_type in GET /api/telemetry/:id response" do
payload = [
{
"id" => 24_004,
"node_id" => "!teltype04",
"rx_time" => reference_time.to_i - 5,
"device_metrics" => { "battery_level" => 70, "channelUtilization" => 30 },
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/telemetry/!teltype04", {}, auth_headers
expect(last_response).to be_ok
entries = JSON.parse(last_response.body)
entry = entries.find { |e| e["id"] == 24_004 }
expect(entry).not_to be_nil
expect(entry["telemetry_type"]).to eq("device")
end
it "infers telemetry_type='air_quality' from air_quality_metrics in the payload" do
payload = [
{
"id" => 24_005,
"node_id" => "!teltype05",
"rx_time" => reference_time.to_i - 40,
"air_quality_metrics" => { "iaq" => 72, "pm25" => 8 },
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_005, "air_quality")
end
it "rejects an invalid telemetry_type and falls back to metric inference" do
payload = [
{
"id" => 24_006,
"node_id" => "!teltype06",
"rx_time" => reference_time.to_i - 50,
"telemetry_type" => "bogus_value",
"device_metrics" => { "battery_level" => 55, "channel_utilization" => 20 },
},
]
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
# Invalid explicit type must be discarded; device_metrics inference takes over.
expect_stored_telemetry_type(24_006, "device")
end
it "returns 400 when more than 1000 telemetry packets are provided" do
payload = Array.new(1001) { |i| { "id" => i + 1, "rx_time" => reference_time.to_i - i } }
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many telemetry packets")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM telemetry")
expect(count).to eq(0)
end
end
end
describe "POST /api/traces" do
it "stores traces with hop paths and updates last heard timestamps" do
payload = trace_fixture
post "/api/traces", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
traces = db.execute("SELECT * FROM traces ORDER BY rx_time DESC")
expect(traces.size).to eq(payload.size)
primary = traces.find { |row| row["id"] == payload.first["id"] }
expect(primary["request_id"]).to eq(payload.first["request_id"])
expect(primary["src"]).to eq(payload.first["src"])
expect(primary["dest"]).to eq(payload.first["dest"])
expect(primary["rx_time"]).to eq(payload.first["rx_time"])
expect(primary["rx_iso"]).to eq(Time.at(payload.first["rx_time"]).utc.iso8601)
expect(primary["rssi"]).to eq(payload.first["rssi"])
expect(primary["snr"]).to eq(payload.first["snr"])
expect(primary["elapsed_ms"]).to eq(payload.first["elapsed_ms"])
primary_hops = db.execute(
"SELECT hop_index, node_id FROM trace_hops WHERE trace_id = ? ORDER BY hop_index",
[primary["id"]],
)
expect(primary_hops.map { |row| row["node_id"] }).to eq(payload.first["hops"])
secondary = traces.find { |row| row["id"] == payload.last["packet_id"] }
expect(secondary["request_id"]).to eq(payload.last["req"])
expect(secondary["src"]).to eq(payload.last["from"])
expect(secondary["dest"]).to eq(payload.last["destination"])
expect(secondary["rssi"]).to be_nil
expect(secondary["snr"]).to eq(payload.last.dig("metrics", "snr"))
expect(secondary["elapsed_ms"]).to eq(payload.last.dig("metrics", "latency_ms"))
secondary_hops = db.execute(
"SELECT hop_index, node_id FROM trace_hops WHERE trace_id = ? ORDER BY hop_index",
[secondary["id"]],
)
expect(secondary_hops.map { |row| row["node_id"] }).to eq([0xBEADF00D, 19_088_743])
node_ids = [
payload.first["src"],
payload.first["dest"],
payload.first["hops"][1],
0xBEADF00D,
].map { |num| format("!%08x", num & 0xFFFFFFFF) }
placeholders = node_ids.map { "?" }.join(",")
rows = db.execute("SELECT node_id, last_heard FROM nodes WHERE node_id IN (#{placeholders})", node_ids)
expect(rows.size).to eq(node_ids.size)
latest_last_heard = rows.map { |row| row["last_heard"] }.max
expect(latest_last_heard).to eq(payload.first["rx_time"])
end
end
it "accepts traceroutes without metrics or RSSI fields" do
allow(Time).to receive(:now).and_return(reference_time)
payload = [
{
"id" => 9_003,
"request_id" => 42,
"src" => 0xAAAA0001,
"dest" => 0xAAAA0002,
"rx_time" => reference_time.to_i - 1,
"hops" => [0xAAAA0001, 0xAAAA0003, 0xAAAA0002],
},
]
post "/api/traces", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
stored = db.get_first_row("SELECT * FROM traces WHERE id = ?", [payload.first["id"]])
expect(stored["rx_time"]).to eq(payload.first["rx_time"])
expect(stored["rx_iso"]).to eq(Time.at(payload.first["rx_time"]).utc.iso8601)
expect(stored["rssi"]).to be_nil
expect(stored["snr"]).to be_nil
expect(stored["elapsed_ms"]).to be_nil
hops = db.execute(
"SELECT hop_index, node_id FROM trace_hops WHERE trace_id = ? ORDER BY hop_index",
[stored["id"]],
)
expect(hops.map { |row| row["node_id"] }).to eq(payload.first["hops"])
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/traces", "{", auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON")
end
it "stores trace ingestor and preserves the first reporter" do
payload = {
"id" => 31_001,
"request_id" => 77,
"src" => 0x10000001,
"dest" => 0x10000002,
"rx_time" => reference_time.to_i - 50,
"hops" => [0x10000001, 0x10000002],
"ingestor" => "!aaaa0001",
}
update = payload.merge("snr" => 7.5, "ingestor" => "!bbbb0002")
post_twice_for_ingestor("/api/traces", payload, update)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT snr, ingestor FROM traces WHERE id = ?", [payload["id"]])
expect_same_value(row["snr"], 7.5)
expect(row["ingestor"]).to eq("!aaaa0001")
end
end
it "returns 400 when more than 1000 traces are provided" do
payload = Array.new(1001) { |i| { "id" => i + 1, "rx_time" => reference_time.to_i - i } }
post "/api/traces", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many traces")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM traces")
expect(count).to eq(0)
end
end
end
it "returns 400 when more than 1000 messages are provided" do
payload = Array.new(1001) { |i| { "packet_id" => i + 1 } }
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "too many messages")
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM messages")
expect(count).to eq(0)
end
end
it "accepts array payloads, normalizes node references, and skips messages without an id" do
node_id = "!spec-normalized"
node_payload = {
node_id => {
"num" => 123,
"user" => { "shortName" => "Spec" },
"lastHeard" => reference_time.to_i - 60,
"position" => { "time" => reference_time.to_i - 120 },
},
}
post "/api/nodes", node_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
messages_payload = [
{
"packet_id" => 101,
"from_id" => "123",
"text" => "normalized",
},
{
"packet_id" => 102,
"from_id" => " ",
"text" => "blank",
},
{
"text" => "missing id",
},
]
post "/api/messages", messages_payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
rows = db.execute(
"SELECT id, from_id, to_id, rx_time, rx_iso, text, encrypted FROM messages ORDER BY id",
)
expect(rows.size).to eq(2)
first, second = rows
expect(first["id"]).to eq(101)
expect(first["from_id"]).to eq(node_id)
expect(first).not_to have_key("from_node_id")
expect(first).not_to have_key("from_node_num")
expect(first["rx_time"]).to eq(reference_time.to_i)
expect(first["rx_iso"]).to eq(reference_time.utc.iso8601)
expect(first["text"]).to eq("normalized")
expect(first).not_to have_key("to_node_id")
expect(first).not_to have_key("to_node_num")
expect(first["encrypted"]).to be_nil
expect(second["id"]).to eq(102)
expect(second["from_id"]).to be_nil
expect(second).not_to have_key("from_node_id")
expect(second).not_to have_key("from_node_num")
expect(second["rx_time"]).to eq(reference_time.to_i)
expect(second["rx_iso"]).to eq(reference_time.utc.iso8601)
expect(second["text"]).to eq("blank")
expect(second).not_to have_key("to_node_id")
expect(second).not_to have_key("to_node_num")
expect(second["encrypted"]).to be_nil
end
end
it "stores encrypted messages and resolves node references" do
sender_id = "!feedc0de"
sender_num = 0xfeedc0de
receiver_id = "!c0ffee99"
receiver_num = 0xc0ffee99
sender_node = {
"node_id" => sender_id,
"short_name" => "EncS",
"long_name" => "Encrypted Sender",
"hw_model" => "TEST",
"role" => "CLIENT",
"snr" => 5.5,
"battery_level" => 80.0,
"voltage" => 3.9,
"last_heard" => reference_time.to_i - 30,
"position_time" => reference_time.to_i - 60,
"latitude" => 52.1,
"longitude" => 13.1,
"altitude" => 42.0,
}
sender_payload = build_node_payload(sender_node)
sender_payload["num"] = sender_num
receiver_node = {
"node_id" => receiver_id,
"short_name" => "EncR",
"long_name" => "Encrypted Receiver",
"hw_model" => "TEST",
"role" => "CLIENT",
"snr" => 4.25,
"battery_level" => 75.0,
"voltage" => 3.8,
"last_heard" => reference_time.to_i - 40,
"position_time" => reference_time.to_i - 70,
"latitude" => 52.2,
"longitude" => 13.2,
"altitude" => 35.0,
}
receiver_payload = build_node_payload(receiver_node)
receiver_payload["num"] = receiver_num
post "/api/nodes", { sender_id => sender_payload }.to_json, auth_headers
expect(last_response.status).to eq(201)
post "/api/nodes", { receiver_id => receiver_payload }.to_json, auth_headers
expect(last_response.status).to eq(201)
encrypted_b64 = Base64.strict_encode64("secret message")
payload = {
"packet_id" => 777_001,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => sender_num.to_s,
"to_id" => receiver_id,
"channel" => 8,
"portnum" => "TEXT_MESSAGE_APP",
"encrypted" => encrypted_b64,
"snr" => -12.5,
"rssi" => -109,
"hop_limit" => 3,
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT from_id, to_id, text, encrypted FROM messages WHERE id = ?",
[777_001],
)
expect(row["from_id"]).to eq(sender_id)
expect(row["to_id"]).to eq(receiver_id)
expect(row["text"]).to be_nil
expect(row["encrypted"]).to eq(encrypted_b64)
node_row = db.get_first_row(
SELECT_NODE_LAST_HEARD_SQL,
[sender_id],
)
expect(node_row["last_heard"]).to eq(payload["rx_time"])
end
get "/api/messages"
expect(last_response).to be_ok
default_messages = JSON.parse(last_response.body)
expect(default_messages).to be_an(Array)
expect(default_messages.map { |row| row["id"] }).not_to include(payload["packet_id"])
get "/api/messages?encrypted=1"
expect(last_response).to be_ok
messages = JSON.parse(last_response.body)
expect(messages).to be_an(Array)
encrypted_entry = messages.find { |row| row["id"] == payload["packet_id"] }
expect(encrypted_entry).not_to be_nil
expect(encrypted_entry["encrypted"]).to eq(encrypted_b64)
expect(encrypted_entry["text"]).to be_nil
expect(encrypted_entry["from_id"]).to eq(sender_id)
expect(encrypted_entry["to_id"]).to eq(receiver_id)
get "/api/messages/#{receiver_id}"
expect(last_response).to be_ok
node_default = JSON.parse(last_response.body)
expect(node_default.map { |row| row["id"] }).not_to include(payload["packet_id"])
get "/api/messages/#{receiver_id}?encrypted=1"
expect(last_response).to be_ok
node_messages = JSON.parse(last_response.body)
node_entry = node_messages.find { |row| row["id"] == payload["packet_id"] }
expect(node_entry).not_to be_nil
expect(node_entry["encrypted"]).to eq(encrypted_b64)
expect(node_entry["text"]).to be_nil
expect(node_entry["from_id"]).to eq(sender_id)
expect(node_entry["to_id"]).to eq(receiver_id)
end
it "keeps encrypted text-port messages even when the PSK is configured" do
psk_b64 = "Nmh7EooP2Tsc+7pvPwXLcEDDuYhk+fBo2GLnbA1Y1sg="
previous_psk = ENV["MESHTASTIC_PSK_B64"]
ENV["MESHTASTIC_PSK_B64"] = psk_b64
begin
payload = {
"packet_id" => 3_915_687_257,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!9e95cf60",
"channel" => 35,
"portnum" => "TEXT_MESSAGE_APP",
"encrypted" => "Q1R7tgI5yXzMXu/3",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted, channel_name FROM messages WHERE id = ?",
[payload["packet_id"]],
)
expect(row["text"]).to be_nil
expect(row["encrypted"]).to eq("Q1R7tgI5yXzMXu/3")
expect(row["channel_name"]).to be_nil
end
ensure
if previous_psk.nil?
ENV.delete("MESHTASTIC_PSK_B64")
else
ENV["MESHTASTIC_PSK_B64"] = previous_psk
end
end
end
it "keeps encrypted payloads when the decrypted portnum is not text" do
psk_b64 = "Nmh7EooP2Tsc+7pvPwXLcEDDuYhk+fBo2GLnbA1Y1sg="
previous_psk = ENV["MESHTASTIC_PSK_B64"]
ENV["MESHTASTIC_PSK_B64"] = psk_b64
begin
encode_varint = lambda do |value|
bytes = []
remaining = value
loop do
byte = remaining & 0x7f
remaining >>= 7
if remaining.zero?
bytes << byte
break
end
bytes << (byte | 0x80)
end
bytes.pack("C*")
end
build_data_message = lambda do |portnum, payload|
tag_portnum = (1 << 3) | 0
tag_payload = (2 << 3) | 2
[
tag_portnum,
].pack("C") + encode_varint.call(portnum) +
[tag_payload].pack("C") + encode_varint.call(payload.bytesize) + payload
end
encrypt_message = lambda do |plaintext, packet_id, from_id|
key = PotatoMesh::App::Meshtastic::ChannelHash.expanded_key(psk_b64)
from_num = PotatoMesh::App::Meshtastic::Cipher.normalize_node_num(from_id, nil)
nonce = PotatoMesh::App::Meshtastic::Cipher.build_nonce(packet_id, from_num)
cipher_name = key.bytesize == 16 ? "aes-128-ctr" : "aes-256-ctr"
cipher = OpenSSL::Cipher.new(cipher_name)
cipher.encrypt
cipher.key = key
cipher.iv = nonce
Base64.strict_encode64(cipher.update(plaintext) + cipher.final)
end
payload_bytes = "OK".b
plaintext = build_data_message.call(3, payload_bytes)
encrypted_payload = encrypt_message.call(plaintext, 3_915_687_260, "!9e95cf60")
payload = {
"packet_id" => 3_915_687_260,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!9e95cf60",
"channel" => 35,
"encrypted" => encrypted_payload,
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted, portnum FROM messages WHERE id = ?",
[payload["packet_id"]],
)
expect(row["text"]).to be_nil
expect(row["encrypted"]).to eq(encrypted_payload)
expect(row["portnum"]).to be_nil
end
ensure
if previous_psk.nil?
ENV.delete("MESHTASTIC_PSK_B64")
else
ENV["MESHTASTIC_PSK_B64"] = previous_psk
end
end
end
it "skips decryption in test mode unless PSK is set" do
previous_psk = ENV["MESHTASTIC_PSK_B64"]
previous_rack = ENV["RACK_ENV"]
ENV.delete("MESHTASTIC_PSK_B64")
ENV["RACK_ENV"] = "test"
message = {
"encrypted" => "otu3OyMrTIUlcaisLVDyAnLW",
}
result = PotatoMesh::Application.decrypt_meshtastic_message(
message,
3_189_171_433,
"!7c5b0920",
nil,
3,
)
expect(result).to be_nil
ensure
if previous_psk.nil?
ENV.delete("MESHTASTIC_PSK_B64")
else
ENV["MESHTASTIC_PSK_B64"] = previous_psk
end
if previous_rack.nil?
ENV.delete("RACK_ENV")
else
ENV["RACK_ENV"] = previous_rack
end
end
it "touches node last seen when encrypted payloads cannot be decrypted" do
encoded_payload = Base64.strict_encode64("cipher".b)
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(nil)
allow(PotatoMesh::Application).to receive(:touch_node_last_seen).and_call_original
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 910_010,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
expect(PotatoMesh::Application).to have_received(:touch_node_last_seen).with(
anything,
anything,
anything,
hash_including(source: :message),
)
end
it "stores modem metadata when touching nodes via messages" do
payload = {
"packet_id" => 910_011,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"text" => "modem metadata",
"lora_freq" => 868,
"modem_preset" => "MediumFast",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT lora_freq, modem_preset FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["lora_freq"]).to eq(868)
expect(row["modem_preset"]).to eq("MediumFast")
end
end
it "stores decoded telemetry when decrypting non-text payloads" do
payload_bytes = "telemetry".b
encoded_payload = Base64.strict_encode64(payload_bytes)
telemetry_payload = {
"time" => reference_time.to_i,
"deviceMetrics" => { "batteryLevel" => 77.5 },
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 67,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "TELEMETRY_APP",
"payload" => telemetry_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_001,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"lora_freq" => 868,
"modem_preset" => "MediumFast",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT id, payload_b64, battery_level FROM telemetry WHERE id = ?",
[900_001],
)
expect(row["payload_b64"]).to eq(encoded_payload)
expect(row["battery_level"]).to eq(77.5)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT lora_freq, modem_preset FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["lora_freq"]).to eq(868)
expect(row["modem_preset"]).to eq("MediumFast")
end
end
it "stores decoded positions when decrypting position payloads" do
payload_bytes = "position".b
encoded_payload = Base64.strict_encode64(payload_bytes)
position_payload = {
"latitude_i" => 525598720,
"longitude_i" => 136577024,
"altitude" => 11,
"time" => reference_time.to_i,
"precision_bits" => 13,
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 3,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "POSITION_APP",
"payload" => position_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_002,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"lora_freq" => 868,
"modem_preset" => "MediumFast",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT id, latitude, longitude, altitude, payload_b64 FROM positions WHERE id = ?",
[900_002],
)
expect(row["payload_b64"]).to eq(encoded_payload)
expect(row["latitude"]).to be_within(0.0001).of(52.559872)
expect(row["longitude"]).to be_within(0.0001).of(13.6577024)
expect(row["altitude"]).to eq(11)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT lora_freq, modem_preset FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["lora_freq"]).to eq(868)
expect(row["modem_preset"]).to eq("MediumFast")
end
end
it "normalizes numeric node identifiers to hex ids" do
payload = {
"packet_id" => 920_001,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "1128114236",
"to_id" => "2086340896",
"text" => "numeric ids",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT from_id, to_id FROM messages WHERE id = ?",
[payload["packet_id"]],
)
expect(row["from_id"]).to eq("!433da83c")
expect(row["to_id"]).to eq("!7c5b0920")
end
end
it "clears encrypted payloads when non-text payloads are decoded" do
payload_bytes = "position".b
encoded_payload = Base64.strict_encode64(payload_bytes)
position_payload = {
"latitude_i" => 525598720,
"longitude_i" => 136577024,
"altitude" => 11,
"time" => reference_time.to_i,
"precision_bits" => 13,
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 3,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::Application).to receive(:debug_log).and_call_original
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "POSITION_APP",
"payload" => position_payload,
},
)
allow(PotatoMesh::Application).to receive(:touch_node_last_seen).and_call_original
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_005,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
SELECT_MESSAGE_ENCRYPTED_SQL,
[900_005],
)
expect(row["encrypted"]).to be_nil
end
expect(PotatoMesh::Application).to have_received(:touch_node_last_seen).with(
anything,
anything,
anything,
hash_including(source: :position),
)
expect(PotatoMesh::Application).not_to have_received(:touch_node_last_seen).with(
anything,
anything,
anything,
hash_including(source: :message),
)
expect(PotatoMesh::Application).to have_received(:debug_log).with(
"Cleared encrypted payload after decoding",
hash_including(context: "data_processing.insert_message", message_id: 900_005, portnum: 3),
)
end
it "keeps encrypted payloads when decoded neighbor data is empty" do
payload_bytes = "neighbor".b
encoded_payload = Base64.strict_encode64(payload_bytes)
neighbor_payload = {
"node_id" => "!7c5b0920",
"neighbors" => [],
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 71,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "NEIGHBORINFO_APP",
"payload" => neighbor_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_006,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
SELECT_MESSAGE_ENCRYPTED_SQL,
[900_006],
)
expect(row["encrypted"]).to eq(encoded_payload)
end
end
it "updates node modem metadata when touching last seen" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES (?,?,?)",
["!7c5b0920", reference_time.to_i - 10, reference_time.to_i - 10],
)
PotatoMesh::Application.touch_node_last_seen(
db,
"!7c5b0920",
nil,
rx_time: reference_time.to_i,
source: :message,
lora_freq: 868,
modem_preset: "MediumFast",
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT lora_freq, modem_preset FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["lora_freq"]).to eq(868)
expect(row["modem_preset"]).to eq("MediumFast")
end
end
it "preserves modem metadata when touch last seen omits it" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard, lora_freq, modem_preset) VALUES (?,?,?,?,?)",
["!7c5b0920", reference_time.to_i - 10, reference_time.to_i - 10, 868, "MediumFast"],
)
PotatoMesh::Application.touch_node_last_seen(
db,
"!7c5b0920",
nil,
rx_time: reference_time.to_i,
source: :message,
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT lora_freq, modem_preset FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["lora_freq"]).to eq(868)
expect(row["modem_preset"]).to eq("MediumFast")
end
end
it "skips decrypted payload storage when portnum is unsupported" do
with_db do |db|
stored = PotatoMesh::Application.store_decrypted_payload(
db,
{ "lora_freq" => 868, "modem_preset" => "MediumFast" },
900_007,
{ payload: "ok".b, portnum: 5, text: nil },
rx_time: reference_time.to_i,
rx_iso: reference_time.utc.iso8601,
from_id: "!7c5b0920",
to_id: "^all",
channel: 0,
portnum: 5,
hop_limit: 2,
snr: 1.0,
rssi: -70,
)
expect(stored).to be(false)
end
end
it "stores decoded neighbors when decrypting neighborinfo payloads" do
payload_bytes = "neighbor".b
encoded_payload = Base64.strict_encode64(payload_bytes)
neighbor_payload = {
"node_id" => "!7c5b0920",
"neighbors" => [
{ "node_id" => "!1d60dd3c", "snr" => 4.5, "last_rx_time" => reference_time.to_i },
],
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 71,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "NEIGHBORINFO_APP",
"payload" => neighbor_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_003,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, neighbor_id, snr FROM neighbors WHERE node_id = ? AND neighbor_id = ?",
["!7c5b0920", "!1d60dd3c"],
)
expect(row["node_id"]).to eq("!7c5b0920")
expect(row["neighbor_id"]).to eq("!1d60dd3c")
expect(row["snr"]).to eq(4.5)
end
end
it "stores decoded traces when decrypting traceroute payloads" do
payload_bytes = "trace".b
encoded_payload = Base64.strict_encode64(payload_bytes)
trace_payload = {
"route" => [1, 2, 3, 4],
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 70,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "TRACEROUTE_APP",
"payload" => trace_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_004,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
trace_row = db.get_first_row(
"SELECT id, dest FROM traces WHERE id = ?",
[900_004],
)
hop_rows = db.execute(
"SELECT hop_index, node_id FROM trace_hops WHERE trace_id = ? ORDER BY hop_index",
[900_004],
)
hop_node_ids = hop_rows.map do |row|
if row.is_a?(Hash)
row["node_id"] || row[:node_id] || row[1]
else
row[1]
end
end
expect(trace_row["id"]).to eq(900_004)
expect(trace_row["dest"]).to eq(4)
expect(hop_node_ids).to eq([1, 2, 3, 4])
end
end
it "keeps encrypted messages when decrypted payload resolves to text portnum" do
encrypted_payload = Base64.strict_encode64("cipher".b)
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
nil,
{
portnum: 1,
payload: "plain".b,
text: "decoded",
channel_name: nil,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 910_001,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encrypted_payload,
},
)
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 910_001,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encrypted_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted FROM messages WHERE id = ?",
[910_001],
)
expect(row["text"]).to be_nil
expect(row["encrypted"]).to eq(encrypted_payload)
end
end
it "stores decoded node data when decrypting nodeinfo payloads" do
payload_bytes = "nodeinfo".b
encoded_payload = Base64.strict_encode64(payload_bytes)
node_payload = {
"id" => "!7c5b0920",
"num" => 2_085_057_824,
"last_heard" => reference_time.to_i,
"user" => {
"short_name" => "NODE",
"long_name" => NODE_INFO_LONG_NAME,
"hw_model" => "TBEAM",
"role" => "CLIENT",
},
}
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 4,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "NODEINFO_APP",
"payload" => node_payload,
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_008,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, short_name, long_name, hw_model FROM nodes WHERE node_id = ?",
["!7c5b0920"],
)
expect(row["node_id"]).to eq("!7c5b0920")
expect(row["short_name"]).to eq("NODE")
expect(row["long_name"]).to eq(NODE_INFO_LONG_NAME)
expect(row["hw_model"]).to eq("TBEAM")
end
end
it "keeps encrypted payloads when decoded nodeinfo payload is invalid" do
payload_bytes = "nodeinfo".b
encoded_payload = Base64.strict_encode64(payload_bytes)
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 4,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "NODEINFO_APP",
"payload" => {},
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_009,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
SELECT_MESSAGE_ENCRYPTED_SQL,
[900_009],
)
expect(row["encrypted"]).to eq(encoded_payload)
end
end
it "keeps encrypted payloads when decoded nodeinfo payload lacks identifying user fields" do
payload_bytes = "nodeinfo".b
encoded_payload = Base64.strict_encode64(payload_bytes)
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(
{
portnum: 4,
payload: payload_bytes,
text: nil,
channel_name: nil,
},
)
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(
{
"type" => "NODEINFO_APP",
"payload" => { "id" => "!7c5b0920", "num" => 2_085_057_824 },
},
)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 900_010,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encoded_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
SELECT_MESSAGE_ENCRYPTED_SQL,
[900_010],
)
expect(row["encrypted"]).to eq(encoded_payload)
end
end
it "normalizes snake_case decoded nodeinfo payloads" do
payload = {
"user" => {
"short_name" => "NODE",
"long_name" => NODE_INFO_LONG_NAME,
"hw_model" => "TBEAM",
"public_key" => "pk",
"is_unmessagable" => true,
},
"device_metrics" => {
"battery_level" => 87,
"channel_utilization" => 12.5,
"air_util_tx" => 2.75,
"uptime_seconds" => 1200,
},
"position" => {
"precision_bits" => 13,
"location_source" => "LOC_MANUAL",
},
"last_heard" => reference_time.to_i,
"hops_away" => 2,
"is_favorite" => true,
"hw_model" => "TBEAM",
}
normalized = PotatoMesh::Application.send(:normalize_decrypted_nodeinfo_payload, payload)
expect(normalized.dig("user", "shortName")).to eq("NODE")
expect(normalized.dig("user", "longName")).to eq(NODE_INFO_LONG_NAME)
expect(normalized.dig("user", "hwModel")).to eq("TBEAM")
expect(normalized.dig("user", "publicKey")).to eq("pk")
expect(normalized.dig("user", "isUnmessagable")).to be(true)
expect(normalized.dig("deviceMetrics", "batteryLevel")).to eq(87)
expect(normalized.dig("deviceMetrics", "channelUtilization")).to eq(12.5)
expect(normalized.dig("deviceMetrics", "airUtilTx")).to eq(2.75)
expect(normalized.dig("deviceMetrics", "uptimeSeconds")).to eq(1200)
expect(normalized.dig("position", "precisionBits")).to eq(13)
expect(normalized.dig("position", "locationSource")).to eq("LOC_MANUAL")
expect(normalized["lastHeard"]).to eq(reference_time.to_i)
expect(normalized["hopsAway"]).to eq(2)
expect(normalized["isFavorite"]).to be(true)
expect(normalized["hwModel"]).to eq("TBEAM")
end
it "rejects malformed normalized nodeinfo payloads" do
invalid_payload = {
"user" => { "shortName" => "NODE" },
"deviceMetrics" => "invalid",
"position" => "invalid",
}
valid = PotatoMesh::Application.send(:valid_decrypted_nodeinfo_payload?, invalid_payload)
normalized = PotatoMesh::Application.send(:normalize_decrypted_nodeinfo_payload, nil)
expect(valid).to be(false)
expect(normalized).to eq({})
end
it "prefers decrypted message fields over encrypted ones" do
encrypted_payload = Base64.strict_encode64("cipher".b)
encrypted_message = {
"packet_id" => 3_189_171_433,
"rx_time" => 1_767_957_187,
"rx_iso" => Time.at(1_767_957_187).utc.iso8601,
"from_id" => "!7c5b0920",
"to_id" => "^all",
"channel" => 3,
"encrypted" => encrypted_payload,
"rssi" => -117,
"hop_limit" => 7,
"lora_freq" => 868,
"modem_preset" => "MediumFast",
"channel_name" => "PUBLIC",
"snr" => -14.0,
}
decrypted_message = {
"packet_id" => 3_189_171_433,
"rx_time" => 1_767_957_191,
"rx_iso" => Time.at(1_767_957_191).utc.iso8601,
"from_id" => "!7c5b0920",
"to_id" => "^all",
"channel" => 7,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "FF-TB Beacon",
"rssi" => -79,
"hop_limit" => 5,
"lora_freq" => 868,
"modem_preset" => "MediumFast",
"channel_name" => "PUBLIC",
"snr" => 9.75,
}
with_db do |db|
PotatoMesh::Application.insert_message(db, encrypted_message)
PotatoMesh::Application.insert_message(db, decrypted_message)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted, channel, rssi, hop_limit, snr, portnum FROM messages WHERE id = ?",
[encrypted_message["packet_id"]],
)
expect(row["text"]).to eq("FF-TB Beacon")
expect(row["encrypted"]).to be_nil
expect(row["channel"]).to eq(7)
expect(row["rssi"]).to eq(-79)
expect(row["hop_limit"]).to eq(5)
expect(row["snr"]).to eq(9.75)
expect(row["portnum"]).to eq("TEXT_MESSAGE_APP")
end
end
it "clears encrypted data when a plaintext update arrives" do
encrypted_payload = Base64.strict_encode64("cipher".b)
base_time = reference_time.to_i - 20
updated_time = reference_time.to_i - 10
encrypted_message = {
"packet_id" => 3_189_171_434,
"rx_time" => base_time,
"rx_iso" => Time.at(base_time).utc.iso8601,
"from_id" => "!7c5b0920",
"to_id" => "^all",
"channel" => 3,
"encrypted" => encrypted_payload,
"rssi" => -117,
"hop_limit" => 7,
"snr" => -14.0,
}
plaintext_message = {
"packet_id" => 3_189_171_434,
"rx_time" => updated_time,
"rx_iso" => Time.at(updated_time).utc.iso8601,
"from_id" => "!7c5b0920",
"to_id" => "^all",
"channel" => 7,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "FF-TB Beacon",
"rssi" => -79,
"hop_limit" => 5,
"snr" => 9.75,
}
with_db do |db|
PotatoMesh::Application.insert_message(db, encrypted_message)
PotatoMesh::Application.insert_message(db, plaintext_message)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted, channel, rssi, hop_limit, snr, rx_time FROM messages WHERE id = ?",
[encrypted_message["packet_id"]],
)
expect(row["text"]).to eq("FF-TB Beacon")
expect(row["encrypted"]).to be_nil
expect(row["channel"]).to eq(7)
expect(row["rssi"]).to eq(-79)
expect(row["hop_limit"]).to eq(5)
expect(row["snr"]).to eq(9.75)
expect(row["rx_time"]).to eq(updated_time)
end
end
it "does not overwrite decrypted messages with encrypted payloads" do
encrypted_payload = Base64.strict_encode64("cipher".b)
decrypted_text = "decoded"
allow(PotatoMesh::Application).to receive(:decrypt_meshtastic_message).and_return(nil)
with_db do |db|
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 910_002,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"text" => decrypted_text,
},
)
PotatoMesh::Application.insert_message(
db,
{
"packet_id" => 910_002,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.utc.iso8601,
"from_id" => "!7c5b0920",
"encrypted" => encrypted_payload,
},
)
end
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT text, encrypted FROM messages WHERE id = ?",
[910_002],
)
expect(row["text"]).to eq(decrypted_text)
expect(row["encrypted"]).to be_nil
end
end
it "updates node last_heard for plaintext messages" do
node_id = "!plainmsg01"
initial_first = reference_time.to_i - 600
initial_last = reference_time.to_i - 300
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES (?,?,?)",
[node_id, initial_last, initial_first],
)
end
rx_time = reference_time.to_i - 120
payload = {
"packet_id" => 888_001,
"rx_time" => rx_time,
"rx_iso" => Time.at(rx_time).utc.iso8601,
"from_id" => node_id,
"text" => "plaintext update",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
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(initial_first)
end
end
it "stores messages containing SQL control characters without executing them" do
payload = {
"packet_id" => 404,
"from_id" => "attacker",
"text" => "'); DROP TABLE nodes;--",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT id, text FROM messages WHERE id = ?",
[404],
)
expect(row["id"]).to eq(404)
expect(row["text"]).to eq("'); DROP TABLE nodes;--")
tables = db.get_first_value(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='nodes'",
)
expect(tables).to eq(1)
end
end
it "updates existing messages only when sender information is provided" do
message_id = 9001
initial_time = reference_time.to_i - 120
initial_iso = Time.at(initial_time).utc.iso8601
base_payload = {
"packet_id" => message_id,
"rx_time" => initial_time,
"rx_iso" => initial_iso,
"to_id" => "^all",
"channel" => 1,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "initial payload",
"snr" => 7.25,
"rssi" => -58,
"hop_limit" => 2,
}
post "/api/messages", base_payload.merge("from_id" => nil).to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT id, from_id, rx_time, rx_iso, text FROM messages WHERE id = ?", [message_id])
expect(row["from_id"]).to be_nil
expect(row["rx_time"]).to eq(initial_time)
expect(row["rx_iso"]).to eq(initial_iso)
expect(row["text"]).to eq("initial payload")
end
updated_time = initial_time + 60
updated_iso = Time.at(updated_time).utc.iso8601
post "/api/messages", base_payload.merge(
"rx_time" => updated_time,
"rx_iso" => updated_iso,
"text" => "overwritten without sender",
"from_id" => " ",
).to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT id, from_id, rx_time, rx_iso, text FROM messages WHERE id = ?", [message_id])
expect(row["from_id"]).to be_nil
expect(row["rx_time"]).to eq(initial_time)
expect(row["rx_iso"]).to eq(initial_iso)
expect(row["text"]).to eq("initial payload")
end
final_time = updated_time + 30
final_iso = Time.at(final_time).utc.iso8601
post "/api/messages", base_payload.merge(
"rx_time" => final_time,
"rx_iso" => final_iso,
"from" => "!spec-sender",
).to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT id, from_id, rx_time, rx_iso, text FROM messages WHERE id = ?", [message_id])
expect(row["from_id"]).to eq("!spec-sender")
expect(row["rx_time"]).to eq(initial_time)
expect(row["rx_iso"]).to eq(initial_iso)
expect(row["text"]).to eq("initial payload")
end
end
end
describe "GET /api/nodes synthetic flag (SPEC MR4)" do
# A caller must be able to distinguish a name-derived placeholder from a
# key-backed node. The flag is emitted compact-style: `synthetic: true`
# on placeholder rows, absent on real rows (matching the API's existing
# nil/blank compaction; no `synthetic: false` noise on every node).
it "marks synthetic placeholder rows and omits the key on real rows" do
clear_database
now = Time.now.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, role, last_heard, first_heard, protocol, synthetic) VALUES(?,?,?,?,?,?,?,?)",
["!f0b61f1e", "", "Synth Node", "COMPANION", now - 60, now - 60, "meshcore", 1],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, role, last_heard, first_heard, protocol, synthetic) VALUES(?,?,?,?,?,?,?,?)",
["!ae46e493", "ae46", "Real Node", "COMPANION", now - 30, now - 30, "meshcore", 0],
)
end
get "/api/nodes/!f0b61f1e"
expect(last_response).to be_ok
synth_row = JSON.parse(last_response.body)
expect(synth_row["synthetic"]).to eq(true)
get "/api/nodes/!ae46e493"
expect(last_response).to be_ok
real_row = JSON.parse(last_response.body)
expect(real_row).not_to have_key("synthetic")
get "/api/nodes"
expect(last_response).to be_ok
listed = JSON.parse(last_response.body)
flags = listed.to_h { |row| [row["node_id"], row["synthetic"]] }
expect(flags["!f0b61f1e"]).to eq(true)
expect(flags["!ae46e493"]).to be_nil
end
end
describe "GET /api/nodes/:id digit-only hex ids (ACCEPTANCE NL-A2)" do
# Live regression (2026-07-27): the matrix bridge requested
# /api/nodes/27336717 for the MeshCore node !27336717 — an 8-hex id made
# entirely of decimal digits — and the digits-mean-num precedence
# (identity.rb) resolved it as Meshtastic num 27336717, matched nothing,
# and 404'd a node the list endpoint was serving. A bang-less digit-only
# ref that is also a canonical 8-hex id must fall back to the hex-id
# interpretation when the num interpretation matches nothing, while a
# genuine num match keeps its precedence.
it "falls back to the hex-id interpretation when the num lookup misses" do
clear_database
now = Time.now.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?)",
["!27336717", "🦅", "Birdperson", "COMPANION", now - 60, now - 60, "meshcore"],
)
end
get "/api/nodes/27336717"
expect(last_response).to be_ok
row = JSON.parse(last_response.body)
expect(row["node_id"]).to eq("!27336717")
end
it "keeps num precedence when the digits match a real node num" do
clear_database
now = Time.now.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?)",
["!01a11c8d", 27_336_717, "MT", "Meshtastic num match", "CLIENT", now - 30, now - 30, "meshtastic"],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?)",
["!27336717", "🦅", "Birdperson", "COMPANION", now - 60, now - 60, "meshcore"],
)
end
get "/api/nodes/27336717"
expect(last_response).to be_ok
row = JSON.parse(last_response.body)
expect(row["node_id"]).to eq("!01a11c8d")
end
it "does not reinterpret digit refs that are not canonical 8-hex ids" do
clear_database
get "/api/nodes/123"
expect(last_response.status).to eq(404)
end
end
describe "GET /api/nodes" do
# Regression-style coverage for SPEC BP1-BP6: more than MAX_QUERY_LIMIT
# nodes inside the seven-day window must all remain reachable by paging
# backward with an inclusive `before` cursor (the `meshint` 1000-node cap).
it "exposes every in-window node through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
cap = PotatoMesh::App::Queries::MAX_QUERY_LIMIT
total = cap + 500
# Seed more than one page of nodes, all inside the seven-day window, each
# with a distinct last_heard so the keyset cursor is unambiguous.
with_db do |db|
db.transaction do
total.times do |i|
lh = now - 60 - i * 30
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!%08x" % (0x1000 + i), 0x1000 + i, "n#{i}", "Node #{i}", "TBEAM", "CLIENT", lh, lh],
)
end
end
end
# Walk the listing the way an external client (l5yth/meshint) must: pull a
# page, then ask for everything at-or-before the oldest last_heard already
# seen. Without an upper-bound cursor the server cannot return anything
# past the newest `cap` rows, so the walk would stall before reaching them.
recovered = walk_before("/api/nodes", id_key: "node_id", sort_key: "last_heard")
expect(recovered.size).to eq(total)
end
it "before cannot widen the window past the floor and ignores a non-positive cursor" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_last = now - (PotatoMesh::Config.week_seconds + 4 * 60 * 60)
fresh_last = now - 30
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?)",
["!stalenode", "stal", "Stale", "TBEAM", "CLIENT", stale_last, stale_last],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?)",
["!freshnode", "frsh", "Fresh", "TBEAM", "CLIENT", fresh_last, fresh_last],
)
end
# A `before` at "now" cannot reach the stale row: the seven-day floor still
# clamps the lower bound, so `before` only ever narrows, never widens.
get "/api/nodes?before=#{now}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include("!freshnode")
expect(ids).not_to include("!stalenode")
# A `before` older than the floor returns nothing beyond the floor (the only
# in-window row is newer than this ceiling, so the result is empty).
get "/api/nodes?before=#{stale_last}"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to be_empty
# A non-positive / non-integer `before` is ignored (treated as absent), so
# the fresh row still comes back rather than being filtered by a bogus
# ceiling — parity with the /api/messages coercion.
["0", "-5", "abc"].each do |bogus|
get "/api/nodes?before=#{bogus}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include("!freshnode"), "before=#{bogus} should be ignored"
end
end
it "before pagination boundary is inclusive and protocol-neutral" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
boundary = now - 100
with_db do |db|
# Two nodes sharing the exact boundary last_heard, one per protocol, plus
# a newer node above the boundary.
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?)",
["!edgecore0", "ec", "Edge Core", "TBEAM", "CLIENT", boundary, boundary, "meshcore"],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?)",
["!edgetast0", "et", "Edge Tastic", "TBEAM", "CLIENT", boundary, boundary, "meshtastic"],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?)",
["!newer0001", "nw", "Newer", "TBEAM", "CLIENT", now - 10, now - 10, "meshcore"],
)
end
# Inclusive <= ceiling: both rows sharing the boundary second are returned
# when the boundary is used as `before` (the one-row overlap a client dedups).
get "/api/nodes?before=#{boundary}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include("!edgecore0", "!edgetast0")
expect(ids).not_to include("!newer0001")
# `before` composes with `protocol`, privileging neither protocol.
get "/api/nodes?before=#{boundary}&protocol=meshcore"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to eq(["!edgecore0"])
end
it "before pagination honors privacy and opt-out filters" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
marker = PotatoMesh::Config.node_opt_out_marker
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?)",
["!plainnode", "pln", "Plain", "TBEAM", "CLIENT", now - 50, now - 50],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?)",
["!optoutnod", "opt", "Quiet #{marker} One", "TBEAM", "CLIENT", now - 60, now - 60],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, last_heard, first_heard) VALUES(?,?,?,?,?,?,?)",
["!hiddennod", "hid", "Hidden", "TBEAM", "CLIENT_HIDDEN", now - 70, now - 70],
)
end
# A backward-paginated request still applies the opt-out filter: a node
# carrying the marker never appears even when within the `before` ceiling.
get "/api/nodes?before=#{now}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include("!plainnode")
expect(ids).not_to include("!optoutnod")
# In private mode the CLIENT_HIDDEN exclusion also still applies under a
# `before` walk — narrowing can never surface a hidden row (Invariant II).
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/api/nodes?before=#{now}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include("!plainnode")
expect(ids).not_to include("!hiddennod")
expect(ids).not_to include("!optoutnod")
end
it "before bypasses the response cache" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
# Start from a clean cache so a prior example's entry cannot mask the
# behaviour under test (the store is process-global with a monotonic TTL).
PotatoMesh::App::ApiCache.invalidate_all
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES(?,?,?)",
["!cacheaaaa", now - 50, now - 50],
)
end
# Prime the newest-page cache with just node A.
get "/api/nodes?limit=3"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body).map { |r| r["node_id"] }).to eq(["!cacheaaaa"])
# Insert node B directly (no POST, so the cache is not invalidated).
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, last_heard, first_heard) VALUES(?,?,?)",
["!cachebbbb", now - 40, now - 40],
)
end
# A plain request is still served the cached page (proves the cache is live).
get "/api/nodes?limit=3"
expect(JSON.parse(last_response.body).map { |r| r["node_id"] }).to eq(["!cacheaaaa"])
# A `before` request bypasses the cache and sees the fresh node B.
get "/api/nodes?limit=3&before=#{now}"
expect(last_response).to be_ok
before_ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(before_ids).to include("!cacheaaaa", "!cachebbbb")
# Issuing the `before` request did not overwrite the hot newest-page cache
# entry — a subsequent plain request still returns the cached page.
get "/api/nodes?limit=3"
expect(JSON.parse(last_response.body).map { |r| r["node_id"] }).to eq(["!cacheaaaa"])
end
it "returns the stored nodes with derived timestamps" do
import_nodes_fixture
get "/api/nodes?limit=#{nodes_fixture.size}"
expect(last_response).to be_ok
actual = JSON.parse(last_response.body)
expect(actual.size).to eq(nodes_fixture.size)
actual_by_id = actual.each_with_object({}) do |row, acc|
acc[row["node_id"]] = row
end
nodes_fixture.each do |node|
expected = expected_api_node_row(node)
actual_row = actual_by_id.fetch(node["node_id"])
expected.each do |key, value|
expect_api_value(actual_row, key, value)
end
if expected["last_heard"]
expected_last_seen_iso = Time.at(expected["last_heard"]).utc.iso8601
expect(actual_row["last_seen_iso"]).to eq(expected_last_seen_iso)
else
expect(actual_row).not_to have_key("last_seen_iso")
end
raw_position_time = node["position_time"]
# I2: the redundant position ISO key is dropped — only `position_time`
# (unix int) is emitted; clients format it. The 0-sentinel still yields no
# `position_time` at all (issue #782, `coerce_positive_or_nil`).
expect(actual_row).not_to have_key("pos_time_iso")
if raw_position_time.is_a?(Numeric) && raw_position_time > 0
expect(actual_row["position_time"]).to eq(raw_position_time)
end
end
end
it "excludes nodes whose last activity is older than a week from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_last = now - (PotatoMesh::Config.week_seconds + 60)
fresh_last = now - 30
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!stale-node", "stal", "Stale", "TBEAM", "CLIENT", 0.0, stale_last, stale_last],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!fresh-node", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_last, fresh_last],
)
end
get "/api/nodes"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["node_id"] }
expect(ids).to include("!fresh-node")
expect(ids).not_to include("!stale-node")
get "/api/nodes/!stale-node"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!stale-node")
get "/api/nodes/!fresh-node"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!fresh-node")
end
it "filters node results using the since parameter for collections and single lookups" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_last_heard = now - 120
recent_last_heard = now - 30
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!older-node", "old", "Older", "TBEAM", "CLIENT", 0.0, older_last_heard, older_last_heard],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!recent-node", "new", "Recent", "TBEAM", "CLIENT", 0.0, recent_last_heard, recent_last_heard],
)
end
get "/api/nodes?since=#{recent_last_heard}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
get "/api/nodes/!older-node?since=#{recent_last_heard}"
expect(last_response.status).to eq(404)
get "/api/nodes/!recent-node?since=#{recent_last_heard}"
expect(last_response).to be_ok
detail = JSON.parse(last_response.body)
expect(detail["node_id"]).to eq("!recent-node")
end
it "omits blank values from node responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, battery_level, voltage, last_heard, first_heard, uptime_seconds, channel_utilization, air_util_tx, position_time, location_source, precision_bits, latitude, longitude, altitude) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
"!blank",
" ",
nil,
"",
nil,
nil,
nil,
nil,
now,
now,
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
nil,
],
)
end
get "/api/nodes"
expect(last_response).to be_ok
nodes = JSON.parse(last_response.body)
expect(nodes.length).to eq(1)
entry = nodes.first
expect(entry["node_id"]).to eq("!blank")
%w[short_name long_name hw_model snr battery_level voltage uptime_seconds channel_utilization air_util_tx position_time location_source precision_bits latitude longitude altitude].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
expect(entry["role"]).to eq("CLIENT")
expect(entry["last_heard"]).to eq(now)
expect(entry["first_heard"]).to eq(now)
expect(entry["last_seen_iso"]).to eq(Time.at(now).utc.iso8601)
expect(entry).not_to have_key("pos_time_iso")
get "/api/nodes/!blank"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!blank")
expect(payload).not_to have_key("short_name")
expect(payload).not_to have_key("hw_model")
end
# Regression for issue #782: when a legacy row stores `position_time = 0`
# (the Meshtastic "no GPS lock" sentinel), the API used to leak the field
# back as `"1970-01-01T00:00:00Z"` because Ruby treated `0` as truthy in
# the guard around `Time.at(pt).iso8601`. After routing the column through
# `coerce_positive_or_nil`, neither `position_time` nor `pos_time_iso` may
# appear in the JSON response.
it "never leaks 1970-01-01 ISO strings for sentinel position_time = 0" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, role, last_heard, first_heard, position_time, latitude, longitude) " \
"VALUES(?,?,?,?,?,?,?,?,?)",
["!sentinel", 0x5e_57_1e_71, "snt", "CLIENT", now, now, 0, 0.0, 0.0],
)
end
get "/api/nodes"
expect(last_response).to be_ok
body = last_response.body
expect(body).not_to include("1970-01-01")
entry = JSON.parse(body).find { |row| row["node_id"] == "!sentinel" }
expect(entry).not_to be_nil
expect(entry).not_to have_key("pos_time_iso")
expect(entry).not_to have_key("position_time")
end
end
describe "API casing consistency" do
it "exposes the /version config block in snake_case" do
get "/version"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
cfg = payload["config"]
expect(cfg).to include(
"site_name", "map_center", "private_mode", "instance_domain",
"contact_link", "contact_link_url", "max_distance_km",
"refresh_interval_seconds"
)
expect(cfg["map_center"]).to include("lat", "lon")
# The pre-0.7.0 camelCase config keys are gone (breaking /version change).
%w[siteName mapCenter privateMode instanceDomain contactLink contactLinkUrl maxDistanceKm refreshIntervalSeconds].each do |camel|
expect(cfg).not_to have_key(camel)
end
expect(payload).to have_key("last_node_update")
expect(payload).not_to have_key("lastNodeUpdate")
end
it "accepts snake_case node fields on POST /api/nodes alongside camelCase" do
now = reference_time.to_i
payload = {
"!aabbccdd" => {
"num" => 0xAABBCCDD,
"last_heard" => now,
"user" => { "short_name" => "SNAK", "long_name" => "Snake Case Node", "hw_model" => "TBEAM" },
"device_metrics" => { "battery_level" => 77 },
"position" => { "latitude" => 52.0, "longitude" => 13.0 },
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/nodes?limit=1000"
row = JSON.parse(last_response.body).find { |r| r["node_id"] == "!aabbccdd" }
expect(row).not_to be_nil
expect(row["short_name"]).to eq("SNAK")
expect(row["long_name"]).to eq("Snake Case Node")
expect(row["hw_model"]).to eq("TBEAM")
expect(row["battery_level"]).to eq(77)
end
end
describe "POST ingest status codes" do
it "returns 201 Created on a successful node ingest (consistent with /api/instances)" do
payload = { "!c0debabe" => { "num" => 0xC0DEBABE, "last_heard" => reference_time.to_i } }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
describe "POST payload validation" do
%w[messages positions telemetry neighbors traces waypoints].each do |endpoint|
it "rejects a non-array/non-object payload on /api/#{endpoint} with 400" do
post "/api/#{endpoint}", '"garbage"', auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid payload")
end
end
end
describe "GET /api/stats/activity" do
it "serves a snake_case-param packets/hour time-series" do
clear_database
now = reference_time.to_i
allow(Time).to receive(:now).and_return(reference_time)
with_db do |db|
db.execute(
"INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)",
["!core0001", now - 100, 3600, "meshcore"],
)
db.execute(
"INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)",
["!tast0001", now - 100, 1800, "meshtastic"],
)
end
get "/api/stats/activity?window_seconds=86400&bucket_seconds=3600"
expect(last_response).to be_ok
series = JSON.parse(last_response.body)
expect(series).to be_an(Array)
expect(series.length).to eq(1)
bucket = series.first
expect(bucket).to have_key("bucket_start")
expect(bucket).to have_key("bucket_end")
expect(bucket["meshcore"]).to eq(3600)
expect(bucket["meshtastic"]).to eq(1800)
expect(bucket["total"]).to eq(5400) # SUM across protocols
end
it "bypasses the cache when since is provided" do
clear_database
get "/api/stats/activity?window_seconds=86400&bucket_seconds=3600&since=#{Time.now.to_i - 3600}"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to be_an(Array)
end
it "defaults to a 24h/1h window when no params are given" do
clear_database
get "/api/stats/activity"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to be_an(Array)
end
it "rejects a non-positive window_seconds" do
get "/api/stats/activity?window_seconds=0&bucket_seconds=3600"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "window_seconds must be positive")
end
it "rejects a non-positive bucket_seconds" do
get "/api/stats/activity?window_seconds=86400&bucket_seconds=0"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "bucket_seconds must be positive")
end
it "rejects a bucket too small for the requested window" do
get "/api/stats/activity?window_seconds=86400&bucket_seconds=1"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "bucket_seconds too small for requested window")
end
end
describe "GET /api/stats" do
it "exposes the MA4 packets/hour rate as an additive <scope>.packets.hour metric" do
clear_database
now = reference_time.to_i
allow(Time).to receive(:now).and_return(reference_time)
with_db do |db|
# meshcore: busiest ingestor 1200 pkts/24h ⇒ 50/h; a quieter second
# ingestor of the same protocol must not inflate the MAX.
db.execute(
"INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)",
["!core0001", now - 100, 1200, "meshcore"],
)
db.execute(
"INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)",
["!core0002", now - 100, 720, "meshcore"],
)
# meshtastic: 720 pkts/24h ⇒ 30/h.
db.execute(
"INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)",
["!tast0001", now - 100, 720, "meshtastic"],
)
end
get "/api/stats"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
# Opt 3 shape: the MA4 rate is folded into each scope as packets.hour;
# no top-level packets_per_hour map remains.
expect(payload).not_to have_key("packets_per_hour")
expect(payload["meshcore"]["packets"]).to eq("hour" => 50)
expect(payload["meshtastic"]["packets"]).to eq("hour" => 30)
expect(payload["total"]["packets"]).to eq("hour" => 80) # SUM of per-protocol MAX = (1200+720)/24
expect(payload["reticulum"]["packets"]).to eq("hour" => 0)
# The additive field leaves the S1 scope × metric × window tree intact.
expect(payload["sampled"]).to eq(false)
expect(payload["total"]).to have_key("nodes")
expect(payload).not_to have_key("active_nodes")
end
it "returns exact SQL-backed activity counts with per-protocol breakdowns" do
clear_database
now = reference_time.to_i
allow(Time).to receive(:now).and_return(reference_time)
with_db do |db|
db.transaction
# 1000 meshtastic nodes heard within the hour (protocol defaults to meshtastic)
1000.times do |index|
heard = now - (index % 1800)
node_id = format("!%08x", index + 1)
db.execute(
INSERT_NODE_WITH_METADATA_SQL,
[node_id, index + 1, "n#{index}", "Node #{index}", "TBEAM", "CLIENT", heard, heard],
)
end
# 5 meshcore nodes heard within the hour
5.times do |index|
heard = now - (index % 1800)
node_id = format("!mc%06x", index + 1)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?,?)",
[node_id, 100_001 + index, "mc#{index}", "MC Node #{index}", "TBEAM", "CLIENT", heard, heard, "meshcore"],
)
end
# 1 meshtastic node heard 2 days ago (week window only)
db.execute(
INSERT_NODE_WITH_METADATA_SQL,
["!week0001", 200_001, "week", "Week Node", "TBEAM", "CLIENT", now - (2 * 86_400), now - (2 * 86_400)],
)
# 1 meshtastic node heard 20 days ago (month window only)
db.execute(
INSERT_NODE_WITH_METADATA_SQL,
["!month001", 200_002, "month", "Month Node", "TBEAM", "CLIENT", now - (20 * 86_400), now - (20 * 86_400)],
)
db.commit
end
get "/api/stats"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["sampled"]).to eq(false)
expect(payload["total"]["nodes"]).to include(
"hour" => 1005,
"day" => 1005,
"week" => 1006,
"month" => 1007,
)
expect(payload["meshcore"]["nodes"]).to include(
"hour" => 5,
"day" => 5,
"week" => 5,
"month" => 5,
)
expect(payload["meshtastic"]["nodes"]).to include(
"hour" => 1000,
"day" => 1000,
"week" => 1001,
"month" => 1002,
)
# The pre-0.7.0 flat key is gone (breaking change).
expect(payload).not_to have_key("active_nodes")
# reticulum is an always-zero forward-looking stub.
expect(payload["reticulum"]["nodes"].values).to all(eq(0))
expect(payload["reticulum"]["messages"].values).to all(eq(0))
expect(payload["reticulum"]["telemetry"].values).to all(eq(0))
end
it "counts messages and the telemetry umbrella with per-protocol breakdowns" do
clear_database
now = reference_time.to_i
allow(Time).to receive(:now).and_return(reference_time)
rx_iso = reference_time.utc.iso8601
with_db do |db|
# Seed the nodes the neighbor row references (neighbors has FK → nodes,
# and app_spec enables PRAGMA foreign_keys = ON).
db.execute("INSERT INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)", ["!bbbb0001", 0xBBBB0001, now, now, "CLIENT"])
db.execute("INSERT INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)", ["!cccc0001", 0xCCCC0001, now, now, "CLIENT"])
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text, protocol) VALUES (?,?,?,?,?,?,?,?)",
[1, now, rx_iso, "!aaaa0001", "!ffffffff", 0, "mc hi", "meshcore"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text, protocol) VALUES (?,?,?,?,?,?,?,?)",
[2, now, rx_iso, "!bbbb0001", "!ffffffff", 0, "mt hi", "meshtastic"],
)
# Telemetry umbrella: one row in each contributing table (meshtastic default).
db.execute("INSERT INTO positions(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, now, rx_iso, "!bbbb0001"])
db.execute("INSERT INTO telemetry(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, now, rx_iso, "!bbbb0001"])
db.execute("INSERT INTO neighbors(node_id, neighbor_id, rx_time) VALUES (?,?,?)", ["!bbbb0001", "!cccc0001", now])
db.execute("INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", [1, now, rx_iso, 1, 2])
end
get "/api/stats"
payload = JSON.parse(last_response.body)
expect(payload["total"]["messages"]["hour"]).to eq(2)
expect(payload["meshcore"]["messages"]["hour"]).to eq(1)
expect(payload["meshtastic"]["messages"]["hour"]).to eq(1)
# positions + telemetry + neighbors + traces, all meshtastic-default → 4.
expect(payload["total"]["telemetry"]["hour"]).to eq(4)
expect(payload["meshtastic"]["telemetry"]["hour"]).to eq(4)
end
it "zeroes message counts but keeps node counts in private mode" do
ENV["PRIVATE"] = "1"
clear_database
now = reference_time.to_i
allow(Time).to receive(:now).and_return(reference_time)
rx_iso = reference_time.utc.iso8601
with_db do |db|
db.execute(
INSERT_NODE_WITH_METADATA_SQL,
["!node0001", 1, "n", "Node", "TBEAM", "CLIENT", now, now],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[1, now, rx_iso, "!node0001", "!ffffffff", 0, "secret"],
)
end
get "/api/stats"
payload = JSON.parse(last_response.body)
# PRIVATE=1 forces every message count to zero (SPEC S5 / Invariant II)...
expect(payload["total"]["messages"].values).to all(eq(0))
# ...while node counts remain visible.
expect(payload["total"]["nodes"]["hour"]).to eq(1)
end
end
describe "GET /api/messages" do
it "returns the stored messages with canonical node references when encrypted messages are included" do
import_nodes_fixture
import_messages_fixture
filtered_messages = messages_fixture.select do |message|
%w[text encrypted emoji reply_id].any? do |key|
value = message[key]
value.is_a?(String) ? !value.strip.empty? : !value.nil?
end
end
get "/api/messages?encrypted=1&limit=#{messages_fixture.size}"
expect(last_response).to be_ok
actual = JSON.parse(last_response.body)
expect(actual.size).to eq(filtered_messages.size)
actual_by_id = actual.each_with_object({}) do |row, acc|
acc[row["id"]] = row
end
node_aliases = {}
nodes_fixture.each do |node|
if (num = node["num"])
node_aliases[num.to_s] = node["node_id"]
end
end
filtered_messages.each do |message|
expected = message.reject { |key, _| key == "node" }
actual_row = actual_by_id.fetch(message["id"])
expected_from_id = expected["from_id"]
if expected_from_id.is_a?(String)
trimmed = expected_from_id.strip
if trimmed.match?(/\A[0-9]+\z/)
expected_from_id = node_aliases[trimmed] || message.dig("node", "node_id") || trimmed
else
expected_from_id = trimmed
end
elsif expected_from_id.nil?
expected_from_id = message.dig("node", "node_id")
end
expect(actual_row["from_id"]).to eq(expected_from_id)
expected_node_id = if expected_from_id.is_a?(String)
expected_from_id
else
node_id = message.dig("node", "node_id")
if node_id.nil?
num = message.dig("node", "num")
node_id = node_aliases[num.to_s] if num
end
node_id
end
if expected_node_id
expect(actual_row["node_id"]).to eq(expected_node_id)
else
expect(actual_row).not_to have_key("node_id")
end
expected_to_id = expected["to_id"]
if expected_to_id.is_a?(String)
trimmed_to = expected_to_id.strip
if trimmed_to.match?(/\A[0-9]+\z/)
expected_to_id = node_aliases[trimmed_to] || trimmed_to
else
expected_to_id = trimmed_to
end
end
expect(actual_row["to_id"]).to eq(expected_to_id)
%w[channel portnum text encrypted].each do |attribute|
expect_api_value(actual_row, attribute, expected[attribute])
end
expect_api_value(actual_row, "snr", expected["snr"])
expect_api_value(actual_row, "rssi", expected["rssi"])
expect_api_value(actual_row, "hop_limit", expected["hop_limit"])
expect_api_value(actual_row, "lora_freq", expected["lora_freq"])
expect_api_value(actual_row, "modem_preset", expected["modem_preset"])
expect_api_value(actual_row, "channel_name", expected["channel_name"])
expect_api_value(actual_row, "reply_id", expected["reply_id"])
expect_api_value(actual_row, "emoji", expected["emoji"])
expect(actual_row["rx_time"]).to eq(expected["rx_time"])
expect(actual_row["rx_iso"]).to eq(expected["rx_iso"])
expect(actual_row).not_to have_key("node")
end
end
context "when DEBUG logging is enabled" do
it "logs diagnostics for messages missing a sender" do
allow(PotatoMesh::Config).to receive(:debug?).and_return(true)
allow(PotatoMesh::Logging).to receive(:log).and_call_original
message_id = 987_654
payload = {
"packet_id" => message_id,
"from_id" => " ",
"text" => "debug logging",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
get "/api/messages"
expect(last_response).to be_ok
expect(PotatoMesh::Logging).to have_received(:log).with(
kind_of(Logger),
:debug,
"Message query produced empty sender",
context: "queries.messages",
stage: "raw_row",
row: a_hash_including("id" => message_id),
)
expect(PotatoMesh::Logging).to have_received(:log).with(
kind_of(Logger),
:debug,
"Message query produced empty sender",
context: "queries.messages",
stage: "after_normalization",
row: a_hash_including("id" => message_id),
)
messages = JSON.parse(last_response.body)
expect(messages.size).to eq(1)
expect(messages.first["from_id"]).to be_nil
end
end
it "excludes messages older than seven days from collection queries while honouring since" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 120)
backfillable_rx = now - (PotatoMesh::Config.week_seconds + 3 * 24 * 60 * 60)
fresh_rx = now - 15
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!fresh", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
[1, stale_rx, Time.at(stale_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "stale", 1.0, -70, 3],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
[2, fresh_rx, Time.at(fresh_rx).utc.iso8601, "!fresh", "!old", 0, "TEXT_MESSAGE_APP", "fresh", 2.0, -60, 3],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
[3, backfillable_rx, Time.at(backfillable_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "backfill", 1.0, -75, 3],
)
end
# Bulk feed defaults to the seven-day window so stale conversations stop
# spamming the dashboard hydrator with backfill lookups.
get "/api/messages"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to eq([2])
# Per-id lookups widen to twenty-eight days so callers can still backfill
# historical context for a specific conversation participant. The route
# matches messages where either ``from_id`` or ``to_id`` references the
# supplied identifier, so the fresh exchange to ``!old`` shows up too.
get "/api/messages/!old"
expect(last_response).to be_ok
scoped = JSON.parse(last_response.body)
expect(scoped.map { |row| row["id"] }).to contain_exactly(1, 2, 3)
# The since parameter overrides the floor when it is more restrictive.
get "/api/messages?since=#{fresh_rx}"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
get "/api/messages/!old?since=#{fresh_rx}"
expect(last_response).to be_ok
scoped_since = JSON.parse(last_response.body)
expect(scoped_since.map { |row| row["id"] }).to eq([2])
end
# Regression for issue #796: more than MAX_QUERY_LIMIT messages inside the
# seven-day window must all remain reachable. Before the fix the feed had
# no upper-bound cursor, so paging stalled at the newest 1000 rows and every
# older in-window message was invisible.
it "exposes every in-window message through backward pagination (issue #796)" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
cap = PotatoMesh::App::Queries::MAX_QUERY_LIMIT
total = cap + 500
# Seed more than one page of messages, all comfortably inside the
# seven-day window, each with a distinct rx_time so the keyset cursor is
# unambiguous.
with_db do |db|
db.transaction do
total.times do |i|
rx = now - 60 - i * 30
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[1000 + i, rx, Time.at(rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "msg #{i}"],
)
end
end
end
# Walk the feed the way the dashboard client does: pull a page, then ask
# for everything at-or-before the oldest row already seen. Without an
# upper-bound cursor the server cannot return anything past the newest
# `cap` rows, so the loop stalls and never reaches the older messages.
seen = {}
cursor = nil
pages = 0
loop do
url = "/api/messages?limit=#{cap}"
url += "&before=#{cursor}" if cursor
get url
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
# The per-request cap is unchanged: a single response never exceeds it.
expect(rows.size).to be <= cap
added = rows.reject { |row| seen.key?(row["id"]) }
added.each { |row| seen[row["id"]] = true }
pages += 1
break if rows.size < cap # window exhausted
break if added.empty? # no progress (unfixed server ignores `before`)
break if pages >= 10 # hard safety bound against an infinite loop
cursor = rows.map { |row| row["rx_time"] }.min
end
expect(seen.size).to eq(total)
end
it "treats a non-positive before cursor as absent (issue #796)" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES(?,?,?,?,?,?,?)",
[42, now - 30, Time.at(now - 30).utc.iso8601, "!a", "!b", 0, "hi"],
)
end
# before=0 and before=-5 must be ignored (not used as a ceiling), so the
# row still comes back rather than being filtered out by a bogus cursor.
get "/api/messages?before=0"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
get "/api/messages?before=-5"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
end
it "clamps an explicit since older than the seven-day floor up to the floor" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 4 * 60 * 60)
fresh_rx = now - 30
explicit_since = now - (PotatoMesh::Config.week_seconds + 14 * 24 * 60 * 60)
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[301, stale_rx, Time.at(stale_rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "stale"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[302, fresh_rx, Time.at(fresh_rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "fresh"],
)
end
# The caller asked for "everything since three weeks ago" but the route
# silently clamps that up to the seven-day floor — the stale row stays
# excluded. This locks in the contract that ``since`` cannot widen the
# window past the floor.
get "/api/messages?since=#{explicit_since}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["id"] }
expect(ids).to eq([302])
expect(ids).not_to include(301)
end
it "clamps per-id since older than the twenty-eight-day floor up to that floor" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
ancient_rx = now - (PotatoMesh::Config.four_weeks_seconds + 6 * 60 * 60)
backfillable_rx = now - (PotatoMesh::Config.four_weeks_seconds - 60)
explicit_since = now - (PotatoMesh::Config.four_weeks_seconds + 30 * 24 * 60 * 60)
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[401, ancient_rx, Time.at(ancient_rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "ancient"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[402, backfillable_rx, Time.at(backfillable_rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "backfill"],
)
end
get "/api/messages/!a?since=#{explicit_since}"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["id"] }
expect(ids).to eq([402])
expect(ids).not_to include(401)
end
it "excludes per-id messages older than the twenty-eight-day extended window" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
ancient_rx = now - (PotatoMesh::Config.four_weeks_seconds + 60)
backfillable_rx = now - (PotatoMesh::Config.four_weeks_seconds - 60)
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[10, ancient_rx, Time.at(ancient_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "ancient"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[11, backfillable_rx, Time.at(backfillable_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "backfill"],
)
end
get "/api/messages/!old"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["id"] }).to eq([11])
end
end
context "when private mode is enabled" do
before do
ENV["PRIVATE"] = "1"
end
it "returns 404 for GET /api/messages" do
get "/api/messages"
expect(last_response.status).to eq(404)
end
it "returns 404 for HEAD /api/messages" do
head "/api/messages"
expect(last_response.status).to eq(404)
end
it "returns 404 for POST /api/messages" do
post "/api/messages", {}.to_json, auth_headers
expect(last_response.status).to eq(404)
end
it "returns 404 for GET /api/waypoints (message-grade privacy, W3)" do
get "/api/waypoints"
expect(last_response.status).to eq(404)
end
it "returns 404 for HEAD /api/waypoints" do
head "/api/waypoints"
expect(last_response.status).to eq(404)
end
it "returns 404 for the per-author GET /api/waypoints/:id (W11 under W3)" do
get "/api/waypoints/!3769b133"
expect(last_response.status).to eq(404)
end
it "still accepts POST /api/waypoints but publishes no waypoints event (W3)" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
# Unlike messages, waypoint ingest stays open under PRIVATE: data may be
# collected, never exposed (SPEC W3). The change event is suppressed so
# the SSE stream cannot leak activity the read API hides.
post "/api/waypoints", [].to_json, auth_headers
expect(last_response.status).to eq(201)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("waypoints", private_mode: true)
expect(PotatoMesh::App::PubSub.publish("waypoints", private_mode: true)).to eq(0)
end
it "excludes hidden clients from the nodes API" do
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!hidden", "hidn", "Hidden", "TBEAM", "CLIENT_HIDDEN", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!visible", "vis", "Visible", "TBEAM", "CLIENT", 1.0, now, now],
)
end
get "/api/nodes?limit=10"
expect(last_response).to be_ok
nodes = JSON.parse(last_response.body)
ids = nodes.map { |node| node["node_id"] }
expect(ids).to include("!visible")
expect(ids).not_to include("!hidden")
end
it "filters opted-out nodes (\u{1F6D1} in name) from the nodes API" do
clear_database
now = reference_time.to_i
marker = PotatoMesh::Config.node_opt_out_marker
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!quiet001", "QT", "Quiet #{marker} Node", "TBEAM", "CLIENT", 1.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!loud0001", "LD", "Loud Node", "TBEAM", "CLIENT", 1.0, now, now],
)
end
get "/api/nodes?limit=10"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["node_id"] }
expect(ids).to include("!loud0001")
expect(ids).not_to include("!quiet001")
# Per-id lookup must also pretend the opted-out node does not exist.
get "/api/nodes/!quiet001"
expect(last_response.status).to eq(404)
end
it "still ingests opted-out node data even though the API hides it" do
clear_database
marker = PotatoMesh::Config.node_opt_out_marker
payload = {
"!silenced" => {
"num" => 0xdead0001,
"lastHeard" => reference_time.to_i,
"user" => {
"shortName" => "SL",
"longName" => "Silenced #{marker} Node",
},
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
# The row exists in the database — opt-out is a display-time filter,
# not an ingestion-time refusal.
with_db(readonly: true) do |db|
row = db.execute("SELECT node_id, long_name FROM nodes WHERE node_id = ?", ["!silenced"]).first
expect(row).not_to be_nil
expect(row[1]).to include(marker)
end
# Bulk and per-id endpoints both omit the opted-out node.
get "/api/nodes"
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).not_to include("!silenced")
get "/api/nodes/!silenced"
expect(last_response.status).to eq(404)
end
it "removes the chat interface from the homepage" do
get "/"
expect(last_response).to be_ok
body = last_response.body
expect(body).not_to include('<div id="chat"')
expect(body).to include("const CHAT_ENABLED = false;")
expect(body).not_to include("Track nodes, messages, and coverage in real time.")
expect(body).to include("Track nodes and coverage in real time.")
end
it "tells the cold-load boot prefetch to skip message endpoints" do
get "/"
expect(last_response).to be_ok
# The boot prefetch reads data-pm-chat; in private mode it must be false so
# the early prefetch never requests /api/messages (mirrors the 404, PS6).
expect(last_response.body).to include('data-pm-chat="false"')
end
end
describe "GET /api/waypoints" do
# Seed one fresh waypoint through the ingest route so the read path is
# exercised end-to-end (auth → insert → query → JSON).
def seed_waypoint(id, overrides = {})
payload = {
"id" => id,
"node_id" => "!3769b133",
"rx_time" => reference_time.to_i - 60,
"name" => "Pin #{id}",
"latitude" => 52.5,
"longitude" => 13.4,
}.merge(overrides)
post "/api/waypoints", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
payload
end
it "returns stored waypoints as snake_case rows (W4)" do
clear_database
expire = reference_time.to_i + 3600
seed_waypoint(41_206, "description" => "See further", "icon" => 0x2708, "expire" => expire, "locked_to" => 0x3769b133)
get "/api/waypoints"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
row = rows.first
expect(row["id"]).to eq(41_206)
expect(row["node_id"]).to eq("!3769b133")
expect(row["name"]).to eq("Pin 41206")
expect(row["description"]).to eq("See further")
expect(row["icon"]).to eq(0x2708)
expect(row["expire"]).to eq(expire)
expect(row["locked_to"]).to eq("!3769b133")
expect(row["protocol"]).to eq("meshtastic")
end
it "excludes expired waypoints from the read surface (W5)" do
clear_database
seed_waypoint(1, "expire" => reference_time.to_i - 10)
seed_waypoint(2, "expire" => reference_time.to_i + 3600)
seed_waypoint(3)
get "/api/waypoints"
ids = JSON.parse(last_response.body).map { |r| r["id"] }
expect(ids).to contain_exactly(2, 3)
end
it "supports the since/before cursors and bypasses the response cache (BP1/BP7)" do
clear_database
base = reference_time.to_i - 600
seed_waypoint(10, "rx_time" => base)
seed_waypoint(11, "rx_time" => base + 100)
seed_waypoint(12, "rx_time" => base + 200)
get "/api/waypoints?since=#{base + 50}"
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to contain_exactly(11, 12)
# Inclusive upper bound: the boundary row itself is returned.
get "/api/waypoints?before=#{base + 100}"
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to contain_exactly(10, 11)
end
it "filters by protocol via the KNOWN_PROTOCOLS gate (W2)" do
clear_database
seed_waypoint(20)
seed_waypoint(21, "protocol" => "meshcore")
get "/api/waypoints?protocol=meshcore"
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to contain_exactly(21)
# Unknown protocol values are discarded, not applied.
get "/api/waypoints?protocol=bogus"
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to contain_exactly(20, 21)
end
it "serves the default feed from the response cache with a weak etag" do
clear_database
seed_waypoint(30)
get "/api/waypoints"
first_body = last_response.body
expect(last_response.headers["ETag"]).to start_with("W/")
get "/api/waypoints"
expect(last_response.body).to eq(first_body)
end
it "exposes every in-window row through before pagination (BP1)" do
clear_database
base = reference_time.to_i - 3600
1.upto(5) { |i| seed_waypoint(100 + i, "rx_time" => base + i * 60) }
recovered = walk_before("/api/waypoints", id_key: "id", sort_key: "rx_time")
expect(recovered).to include(*(101..105).to_a)
end
it "serves the per-author lookup on GET /api/waypoints/:id (W11)" do
clear_database
seed_waypoint(60)
seed_waypoint(61, "node_id" => "!11223344")
get "/api/waypoints/!3769b133"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |r| r["id"] }
expect(ids).to contain_exactly(60)
expect(last_response.headers["ETag"]).to start_with("W/")
# A blank id segment routes to the bulk collection, not the per-id route;
# an unknown author yields an empty list rather than an error.
get "/api/waypoints/!deadbeef"
expect(JSON.parse(last_response.body)).to eq([])
end
end
describe "GET /api/positions" do
it "exposes every in-window row through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
total = PotatoMesh::App::Queries::MAX_QUERY_LIMIT + 5
with_db do |db|
db.transaction do
total.times do |i|
rx = now - 60 - i * 30
db.execute(
"INSERT INTO positions(id, node_id, rx_time, rx_iso, latitude, longitude) VALUES(?,?,?,?,?,?)",
[1000 + i, "!pos00001", rx, Time.at(rx).utc.iso8601, 1.0, 2.0],
)
end
end
end
recovered = walk_before("/api/positions", id_key: "id", sort_key: "rx_time")
expect(recovered.size).to eq(total)
end
it "returns stored positions ordered by receive time" do
node_id = "!specfetch"
rx_times = [reference_time.to_i - 50, reference_time.to_i - 10]
rx_times.each_with_index do |rx_time, idx|
payload = {
"id" => 20_000 + idx,
"node_id" => node_id,
"rx_time" => rx_time,
"rx_iso" => Time.at(rx_time).utc.iso8601,
"position_time" => rx_time - 5,
"latitude" => 52.0 + idx,
"longitude" => 13.0 + idx,
"location_source" => "LOC_TEST",
"precision_bits" => 7 + idx,
"payload_b64" => "AQI=",
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
end
get "/api/positions?limit=1"
expect(last_response).to be_ok
data = JSON.parse(last_response.body)
expect(data.length).to eq(1)
entry = data.first
expect(entry["id"]).to eq(20_001)
expect(entry["node_id"]).to eq(node_id)
expect(entry["rx_time"]).to eq(rx_times.last)
expect(entry["rx_iso"]).to eq(Time.at(rx_times.last).utc.iso8601)
expect(entry["position_time"]).to eq(rx_times.last - 5)
# I2: position ISO key dropped — only `position_time` (unix int) is emitted.
expect(entry).not_to have_key("position_time_iso")
expect(entry["latitude"]).to eq(53.0)
expect(entry["longitude"]).to eq(14.0)
expect(entry["location_source"]).to eq("LOC_TEST")
expect(entry["precision_bits"]).to eq(8)
expect(entry["payload_b64"]).to eq("AQI=")
end
it "excludes position entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 10)
fresh_rx = now - 20
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[1, "!pos", 42, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 5, 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[2, "!pos", 42, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 5, 53.0, 14.0],
)
end
get "/api/positions"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to eq([2])
get "/api/positions/!pos"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters positions using the since parameter for both global and node queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_rx = now - 180
recent_rx = now - 15
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[10, "!pos-since", 101, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[11, "!pos-since", 101, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 53.0, 14.0],
)
end
get "/api/positions?since=#{recent_rx}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["id"] }).to eq([11])
get "/api/positions/!pos-since?since=#{recent_rx}"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([11])
end
it "omits blank values from position responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude, altitude, location_source, precision_bits, sats_in_view, pdop, payload_b64) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
7,
"!pos-blank",
nil,
now,
" ",
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
"",
],
)
end
get "/api/positions"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!pos-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[node_num position_time latitude longitude altitude location_source precision_bits sats_in_view pdop payload_b64].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/positions/!pos-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("payload_b64")
expect(filtered.first).not_to have_key("location_source")
end
end
describe "GET /api/neighbors" do
it "exposes every in-window row through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
total = PotatoMesh::App::Queries::MAX_QUERY_LIMIT + 5
with_db do |db|
db.transaction do
# neighbors carries FK(node_id) and FK(neighbor_id) -> nodes(node_id),
# so both endpoints must exist as nodes before the relationship rows.
db.execute("INSERT INTO nodes(node_id, last_heard, first_heard) VALUES(?,?,?)", ["!nbr00001", now, now])
total.times do |i|
rx = now - 60 - i * 30
nbr = "!%08x" % (0x20000 + i)
db.execute("INSERT INTO nodes(node_id, last_heard, first_heard) VALUES(?,?,?)", [nbr, rx, rx])
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, rx_time) VALUES(?,?,?)",
["!nbr00001", nbr, rx],
)
end
end
end
recovered = walk_before("/api/neighbors", id_key: "neighbor_id", sort_key: "rx_time")
expect(recovered.size).to eq(total)
end
it "excludes neighbor records older than twenty-eight days from both bulk and per-id queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.four_weeks_seconds + 45)
backfillable_rx = now - (PotatoMesh::Config.four_weeks_seconds - 60)
fresh_rx = now - 10
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!root", "root", "Root", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-mid", "midn", "Neighbor Mid", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!root", "!neighbor-old", 1.0, stale_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!root", "!neighbor-mid", 4.0, backfillable_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!root", "!neighbor-new", 8.0, fresh_rx],
)
end
# Both bulk and per-id queries share the twenty-eight-day extended
# window — neighbours are reported sporadically and would otherwise be
# lost between scrapes.
get "/api/neighbors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new", "!neighbor-mid"])
get "/api/neighbors/!root"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new", "!neighbor-mid"])
end
it "honours the since parameter for neighbor queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_rx = now - 300
recent_rx = now - 30
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!origin-since", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!origin-since", "!neighbor-old", 1.5, older_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!origin-since", "!neighbor-new", 7.5, recent_rx],
)
end
get "/api/neighbors?since=#{recent_rx}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
get "/api/neighbors/!origin-since?since=#{recent_rx}"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
end
it "omits blank values from neighbor responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!origin", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor", "neig", "Neighbor", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!origin", "!neighbor", nil, now],
)
end
get "/api/neighbors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(1)
entry = payload.first
expect(entry["node_id"]).to eq("!origin")
expect(entry["neighbor_id"]).to eq("!neighbor")
expect(entry["rx_time"]).to eq(now)
expect(entry).not_to have_key("snr")
get "/api/neighbors/!origin"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("snr")
end
end
describe "GET /api/telemetry" do
it "exposes every in-window row through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
total = PotatoMesh::App::Queries::MAX_QUERY_LIMIT + 5
with_db do |db|
db.transaction do
total.times do |i|
rx = now - 60 - i * 30
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso) VALUES(?,?,?,?)",
[1000 + i, "!tel00001", rx, Time.at(rx).utc.iso8601],
)
end
end
end
recovered = walk_before("/api/telemetry", id_key: "id", sort_key: "rx_time")
expect(recovered.size).to eq(total)
end
it "returns stored telemetry ordered by receive time" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/telemetry?limit=2"
expect(last_response).to be_ok
data = JSON.parse(last_response.body)
expect(data.length).to eq(2)
latest = telemetry_fixture.max_by { |entry| entry["rx_time"] }
second_latest = telemetry_fixture.sort_by { |entry| entry["rx_time"] }[-2]
first_entry = data.first
expect(first_entry["id"]).to eq(latest["id"])
expect(first_entry["node_id"]).to eq(latest["node_id"])
expect(first_entry["rx_time"]).to eq(latest["rx_time"])
expect(first_entry["telemetry_time"]).to eq(latest["telemetry_time"])
expect(first_entry["telemetry_time_iso"]).to eq(Time.at(latest["telemetry_time"]).utc.iso8601)
expect(first_entry).not_to have_key("device_metrics")
expect_same_value(first_entry["battery_level"], telemetry_metric(latest, "battery_level"))
expected_current = telemetry_metric(latest, "current")
expect_same_value(first_entry["current"], expected_current.nil? ? nil : expected_current / 1000.0)
expect_same_value(first_entry["distance"], telemetry_metric(latest, "distance"))
expect_same_value(first_entry["lux"], telemetry_metric(latest, "lux"))
expect_same_value(first_entry["wind_direction"], telemetry_metric(latest, "wind_direction"))
expect_same_value(first_entry["wind_speed"], telemetry_metric(latest, "wind_speed"))
expect_same_value(first_entry["weight"], telemetry_metric(latest, "weight"))
expect_same_value(first_entry["rainfall_24h"], telemetry_metric(latest, "rainfall_24h", "rainfall24h"))
expect_same_value(first_entry["soil_moisture"], telemetry_metric(latest, "soil_moisture"))
expect_same_value(first_entry["soil_temperature"], telemetry_metric(latest, "soil_temperature"))
second_entry = data.last
expect(second_entry["id"]).to eq(second_latest["id"])
expect(second_entry).not_to have_key("environment_metrics")
expect_api_value(second_entry, "temperature", telemetry_metric(second_latest, "temperature"))
expect_api_value(second_entry, "relative_humidity", telemetry_metric(second_latest, "relative_humidity"))
expect_api_value(second_entry, "barometric_pressure", telemetry_metric(second_latest, "barometric_pressure"))
expect_same_value(second_entry["gas_resistance"], telemetry_metric(second_latest, "gas_resistance"))
expect_same_value(second_entry["iaq"], telemetry_metric(second_latest, "iaq"))
expect_same_value(second_entry["distance"], telemetry_metric(second_latest, "distance"))
expect_same_value(second_entry["lux"], telemetry_metric(second_latest, "lux"))
expect_same_value(second_entry["white_lux"], telemetry_metric(second_latest, "white_lux"))
expect_same_value(second_entry["ir_lux"], telemetry_metric(second_latest, "ir_lux"))
expect_same_value(second_entry["uv_lux"], telemetry_metric(second_latest, "uv_lux"))
expect_same_value(second_entry["wind_direction"], telemetry_metric(second_latest, "wind_direction"))
expect_same_value(second_entry["wind_speed"], telemetry_metric(second_latest, "wind_speed"))
expect_same_value(second_entry["wind_gust"], telemetry_metric(second_latest, "wind_gust"))
expect_same_value(second_entry["wind_lull"], telemetry_metric(second_latest, "wind_lull"))
expect_same_value(second_entry["weight"], telemetry_metric(second_latest, "weight"))
expect_same_value(second_entry["radiation"], telemetry_metric(second_latest, "radiation"))
expect_same_value(second_entry["rainfall_1h"], telemetry_metric(second_latest, "rainfall_1h", "rainfall1h"))
expect_same_value(second_entry["rainfall_24h"], telemetry_metric(second_latest, "rainfall_24h", "rainfall24h"))
expect_same_value(second_entry["soil_moisture"], telemetry_metric(second_latest, "soil_moisture"))
expect_same_value(second_entry["soil_temperature"], telemetry_metric(second_latest, "soil_temperature"))
end
it "excludes telemetry entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 30)
fresh_rx = now - 5
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[1, "!tele", 7, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 60, 10.0, 3.9],
)
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[2, "!tele", 7, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 60, 90.0, 4.1],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to eq([2])
get "/api/telemetry/!tele"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_rx = now - 300
recent_rx = now - 60
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[10, "!tele-since", 21, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 20.0, 3.9],
)
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[11, "!tele-since", 21, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 80.0, 4.1],
)
end
get "/api/telemetry?since=#{recent_rx}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["id"] }).to eq([11])
get "/api/telemetry/!tele-since?since=#{recent_rx}"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([11])
end
it "omits blank values from telemetry responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, channel, portnum, hop_limit, snr, rssi, bitfield, payload_b64, battery_level, voltage, channel_utilization, air_util_tx, uptime_seconds, temperature, relative_humidity) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
77,
"!tele-blank",
nil,
now,
" ",
nil,
nil,
"",
nil,
nil,
nil,
nil,
"",
nil,
nil,
nil,
nil,
nil,
nil,
nil,
],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!tele-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[node_num telemetry_time channel portnum hop_limit snr rssi bitfield payload_b64 battery_level voltage channel_utilization air_util_tx uptime_seconds temperature relative_humidity].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/telemetry/!tele-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("battery_level")
expect(filtered.first).not_to have_key("portnum")
end
it "omits zero-valued battery and voltage metrics from telemetry responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage, uptime_seconds, channel_utilization) VALUES(?,?,?,?,?,?,?,?,?)",
[
88,
"!tele-zero",
now,
Time.at(now).utc.iso8601,
now - 60,
0,
0,
0,
0.5,
],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!tele-zero")
expect(entry["rx_time"]).to eq(now)
expect(entry["telemetry_time"]).to eq(now - 60)
expect(entry).not_to have_key("battery_level")
expect(entry).not_to have_key("voltage")
expect(entry["uptime_seconds"]).to eq(0)
expect(entry["channel_utilization"]).to eq(0.5)
get "/api/telemetry/!tele-zero"
expect(last_response).to be_ok
scoped_rows = JSON.parse(last_response.body)
expect(scoped_rows.length).to eq(1)
expect(scoped_rows.first).not_to have_key("battery_level")
expect(scoped_rows.first).not_to have_key("voltage")
end
end
describe "GET /api/telemetry/aggregated" do
it "returns aggregated telemetry buckets for the requested interval" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/telemetry/aggregated?windowSeconds=86400&bucketSeconds=300"
expect(last_response).to be_ok
buckets = JSON.parse(last_response.body)
expect(buckets).not_to be_empty
a_bucket = buckets.first
expect(a_bucket["bucket_seconds"]).to eq(300)
expect(a_bucket["sample_count"]).to be >= 1
expect(a_bucket["bucket_start"]).to be_a(Integer)
expect(a_bucket["bucket_end"]).to be_a(Integer)
expect(a_bucket["aggregates"]).to be_a(Hash)
expect(a_bucket["aggregates"]).to have_key("battery_level")
expect(a_bucket["aggregates"]["battery_level"]).to include("avg")
expect(a_bucket).not_to have_key("device_metrics")
buckets_by_start = {}
buckets.each do |bucket|
start_time = bucket["bucket_start"]
buckets_by_start[start_time] = bucket if start_time
end
bucket_seconds = 300
current_by_bucket = Hash.new { |hash, key| hash[key] = [] }
telemetry_fixture.each do |entry|
timestamp = entry["rx_time"] || entry["telemetry_time"]
next unless timestamp
bucket_start = (timestamp / bucket_seconds) * bucket_seconds
current_value = telemetry_metric(entry, "current")
next if current_value.nil?
current_by_bucket[bucket_start] << current_value
end
current_by_bucket.each do |bucket_start, values|
bucket = buckets_by_start[bucket_start]
next unless bucket
aggregates = bucket.fetch("aggregates", {})
metrics = aggregates["current"]
expect(metrics).not_to be_nil
expect_same_value(metrics["avg"], values.sum / values.length / 1000.0)
expect_same_value(metrics["min"], values.min / 1000.0)
expect_same_value(metrics["max"], values.max / 1000.0)
end
end
it "applies default window and bucket sizes when parameters are omitted" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/telemetry/aggregated"
expect(last_response).to be_ok
buckets = JSON.parse(last_response.body)
expect(buckets.length).to be >= 1
expect(buckets.first["bucket_seconds"]).to eq(PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS)
end
it "filters aggregated telemetry buckets using the since parameter" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_rx = now - 1800
recent_rx = now - 120
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
[801, "!agg-since", older_rx, Time.at(older_rx).utc.iso8601, older_rx - 30, 30.0],
)
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
[802, "!agg-since", recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 30, 80.0],
)
end
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300&since=#{recent_rx}"
expect(last_response).to be_ok
buckets = JSON.parse(last_response.body)
expect(buckets.length).to eq(1)
aggregates = buckets.first.fetch("aggregates")
expect(aggregates).to have_key("battery_level")
expect_same_value(aggregates.dig("battery_level", "avg"), 80.0)
end
it "omits zero-valued battery and voltage aggregates" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage, channel_utilization) VALUES(?,?,?,?,?,?,?,?)",
[
991,
"!tele-agg-zero",
now,
Time.at(now).utc.iso8601,
now - 30,
0,
0,
0.25,
],
)
end
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300"
expect(last_response).to be_ok
buckets = JSON.parse(last_response.body)
expect(buckets.length).to eq(1)
aggregates = buckets.first.fetch("aggregates")
expect(aggregates).not_to have_key("battery_level")
expect(aggregates).not_to have_key("voltage")
expect(aggregates.dig("channel_utilization", "avg")).to eq(0.25)
end
it "ignores zero-valued telemetry when aggregating mixed buckets" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?)",
[
992,
"!tele-agg-mixed",
now,
Time.at(now).utc.iso8601,
now - 120,
0,
0,
],
)
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?)",
[
993,
"!tele-agg-mixed",
now,
Time.at(now).utc.iso8601,
now - 60,
80.0,
3.7,
],
)
end
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300"
expect(last_response).to be_ok
buckets = JSON.parse(last_response.body)
expect(buckets.length).to eq(1)
aggregates = buckets.first.fetch("aggregates")
expect(aggregates).to have_key("battery_level")
expect(aggregates.dig("battery_level", "avg")).to eq(80.0)
expect(aggregates.dig("battery_level", "min")).to eq(80.0)
expect(aggregates.dig("battery_level", "max")).to eq(80.0)
expect(aggregates.dig("voltage", "avg")).to eq(3.7)
expect(aggregates.dig("voltage", "min")).to eq(3.7)
expect(aggregates.dig("voltage", "max")).to eq(3.7)
end
it "rejects invalid bucket and window parameters" do
get "/api/telemetry/aggregated?windowSeconds=0&bucketSeconds=300"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "windowSeconds must be positive")
get "/api/telemetry/aggregated?windowSeconds=86400&bucketSeconds=0"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "bucketSeconds must be positive")
get "/api/telemetry/aggregated?windowSeconds=86400&bucketSeconds=1"
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "bucketSeconds too small for requested window")
end
it "clamps windowSeconds to the 28-day visibility cap" do
# A 10-year window is well beyond four_weeks_seconds; the bucket size
# is selected so that under the natural 10-year window the bucket count
# would explode past MAX_QUERY_LIMIT and the route would return 400.
# When the cap kicks in correctly the request succeeds with HTTP 200.
huge_window = 10 * 365 * 24 * 60 * 60
bucket = PotatoMesh::Config.four_weeks_seconds / 100
get "/api/telemetry/aggregated?windowSeconds=#{huge_window}&bucketSeconds=#{bucket}"
expect(last_response).to be_ok
end
end
describe "GET /api/traces" do
it "exposes every in-window row through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
total = PotatoMesh::App::Queries::MAX_QUERY_LIMIT + 5
with_db do |db|
db.transaction do
total.times do |i|
rx = now - 60 - i * 30
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso) VALUES(?,?,?)",
[1000 + i, rx, Time.at(rx).utc.iso8601],
)
end
end
end
recovered = walk_before("/api/traces", id_key: "id", sort_key: "rx_time")
expect(recovered.size).to eq(total)
end
it "returns stored traces ordered by receive time" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/traces"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(trace_fixture.length)
expect(payload.map { |row| row["id"] }).to eq([trace_fixture.first["id"], trace_fixture.last["packet_id"]])
latest = payload.first
expect(latest["request_id"]).to eq(trace_fixture.first["request_id"])
expect(latest["src"]).to eq(trace_fixture.first["src"])
expect(latest["dest"]).to eq(trace_fixture.first["dest"])
expect(latest["hops"]).to eq(trace_fixture.first["hops"])
expect(latest["rx_iso"]).to eq(Time.at(trace_fixture.first["rx_time"]).utc.iso8601)
earlier = payload.last
expect(earlier["request_id"]).to eq(trace_fixture.last["req"])
expect(earlier["hops"]).to eq([0xBEADF00D, 19_088_743])
expect(earlier["elapsed_ms"]).to eq(trace_fixture.last.dig("metrics", "latency_ms"))
end
it "filters traces by node reference across sources" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/traces/#{trace_fixture.first["src"]}"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to include(trace_fixture.first["id"], trace_fixture.last["packet_id"])
get "/api/traces/!beadf00d"
expect(last_response).to be_ok
bead_filtered = JSON.parse(last_response.body)
expect(bead_filtered.map { |row| row["id"] }).to eq([trace_fixture.last["packet_id"]])
expect(bead_filtered.first["hops"]).to eq([0xBEADF00D, 19_088_743])
end
it "returns an empty list when no traces are stored" do
clear_database
get "/api/traces"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq([])
end
it "excludes traces older than twenty-eight days" do
clear_database
now = Time.now.to_i
recent_rx = now - (PotatoMesh::Config.four_weeks_seconds / 2)
stale_rx = now - (PotatoMesh::Config.four_weeks_seconds + 60)
payload = [
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
]
post "/api/traces", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/traces"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["id"] }
expect(ids).to eq([50_001])
end
it "filters traces using the since parameter for collection and scoped requests" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
older_rx = now - 300
recent_rx = now - 25
with_db do |db|
db.execute(
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
[60_001, 123, 456, older_rx, Time.at(older_rx).utc.iso8601],
)
db.execute(
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
[60_002, 123, 456, recent_rx, Time.at(recent_rx).utc.iso8601],
)
end
get "/api/traces?since=#{recent_rx}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["id"] }).to eq([60_002])
get "/api/traces/123?since=#{recent_rx}"
expect(last_response).to be_ok
scoped = JSON.parse(last_response.body)
expect(scoped.map { |row| row["id"] }).to eq([60_002])
end
end
describe "GET /api/ingestors" do
it "exposes every in-window row through before pagination" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
total = PotatoMesh::App::Queries::MAX_QUERY_LIMIT + 5
with_db do |db|
db.transaction do
total.times do |i|
ls = now - 60 - i * 30
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!%08x" % (0x30000 + i), ls, ls, "1.0.0"],
)
end
end
end
recovered = walk_before("/api/ingestors", id_key: "node_id", sort_key: "last_seen_time")
expect(recovered.size).to eq(total)
end
it "uses the twenty-eight-day extended window so slow-tick ingestors stay visible" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_seen = now - (PotatoMesh::Config.four_weeks_seconds + 30)
backfillable_seen = now - (PotatoMesh::Config.week_seconds + 24 * 60 * 60)
fresh_seen = now - 60
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)",
["!stale-ing", stale_seen - 60, stale_seen, "0.6.3", "meshtastic"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)",
["!slow-ing", backfillable_seen - 60, backfillable_seen, "0.6.3", "meshtastic"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)",
["!fresh-ing", fresh_seen - 60, fresh_seen, "0.6.3", "meshtastic"],
)
end
get "/api/ingestors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["node_id"] }
# The eight-day-old ingestor is included (was excluded under the seven-day
# default) while the twenty-eight-day-old one stays out.
expect(ids).to contain_exactly("!fresh-ing", "!slow-ing")
end
end
describe "GET /nodes/:id" do
before do
import_nodes_fixture
end
it "renders the node detail page with embedded reference data" do
node = nodes_fixture.first
get "/nodes/#{node["node_id"]}"
expect(last_response).to be_ok
expect(last_response.body).to include("data-node-reference=")
expect(last_response.body).to include(node["node_id"])
end
it "does not render the meta row on the node detail page" do
node = nodes_fixture.first
get "/nodes/#{node["node_id"]}"
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="refreshBtn"')
end
it "returns 404 when the node cannot be located" do
get "/nodes/!deadbeef"
expect(last_response.status).to eq(404)
end
end
describe "request-thread budget" do
it "sizes the Puma thread pool above the SSE subscriber cap (PS9)" do
threads = PotatoMesh::Application.settings.server_settings[:Threads]
expect(threads).to eq(PotatoMesh::Config.puma_threads_setting)
_min, max = threads.split(":").map(&:to_i)
expect(max).to be > PotatoMesh::App::PubSub::MAX_SUBSCRIBERS
expect(max - PotatoMesh::App::PubSub::MAX_SUBSCRIBERS).to be >= PotatoMesh::Config.sse_thread_reserve
end
end
describe "live-update shutdown handling" do
before { PotatoMesh::App::PubSub.reset! }
it "exposes force_shutdown_after to Puma via server_settings" do
expect(PotatoMesh::Application.settings.server_settings).to include(
force_shutdown_after: PotatoMesh::Config.puma_force_shutdown_seconds,
)
end
it "close_live_update_subscribers! closes every open SSE subscriber" do
PotatoMesh::App::PubSub.subscribe
PotatoMesh::App::PubSub.subscribe
expect(PotatoMesh::App::PubSub.subscriber_count).to eq(2)
PotatoMesh::Application.close_live_update_subscribers!
expect(PotatoMesh::App::PubSub.subscriber_count).to eq(0)
end
it "traps INT/TERM and the handler closes the SSE subscribers" do
trapped = {}
fake_trap = ->(signal, &block) { trapped[signal] = block }
PotatoMesh::Application.install_pubsub_shutdown_signal_handlers!(trap: fake_trap)
expect(trapped.keys).to contain_exactly(:INT, :TERM)
PotatoMesh::App::PubSub.subscribe
trapped.fetch(:INT).call # spawns a thread that closes subscribers
50.times do
break if PotatoMesh::App::PubSub.subscriber_count.zero?
sleep 0.01
end
expect(PotatoMesh::App::PubSub.subscriber_count).to eq(0)
end
it "close_live_update_subscribers! swallows errors from reset!" do
allow(PotatoMesh::App::PubSub).to receive(:reset!).and_raise(StandardError, "boom")
expect { PotatoMesh::Application.close_live_update_subscribers! }.not_to raise_error
end
it "run_server! installs the shutdown signal handlers, then runs" do
calls = []
allow(PotatoMesh::Application).to receive(:install_pubsub_shutdown_signal_handlers!) { calls << :install }
allow(PotatoMesh::Application).to receive(:run!) { calls << :run }
PotatoMesh::Application.run_server!
expect(calls).to eq(%i[install run])
end
end
end