mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
601 lines
26 KiB
Ruby
601 lines
26 KiB
Ruby
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require "spec_helper"
|
|
require "sqlite3"
|
|
require "base64"
|
|
require_relative "support/data_processing_harness"
|
|
|
|
# A node row is bound to the key that first named it (SPEC NI2, NI3): a
|
|
# record under another key, or under none, cannot change the row's names,
|
|
# role, hardware model, key, position, destinations or keyed evidence, and
|
|
# cannot merge a same-name chat placeholder into it; it still refreshes
|
|
# +last_heard+ and telemetry. MeshCore rows bind on the full public key and
|
|
# Reticulum rows on the identity hash, so two identities sharing the 4-byte
|
|
# node id keep the first one's names. A new key takes the row over once the
|
|
# stored key's row is positively stale (SPEC MR2). A Meshtastic record that
|
|
# carries a user but no role is a CLIENT (SPEC NI5).
|
|
#
|
|
# The shared "with isolated db" context shortens +four_weeks_seconds+ to one
|
|
# week, so "positively stale" here means keyed evidence older than a week.
|
|
RSpec.describe "Node identity binding" do
|
|
let(:harness_class) { DataProcessingHarness.build }
|
|
|
|
subject(:dp) { harness_class.new }
|
|
|
|
include_context "with isolated db"
|
|
|
|
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
|
|
|
|
# Read one full node row.
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param node_id [String] canonical node id.
|
|
# @return [Hash, nil] the +nodes+ row, or nil when absent.
|
|
def node_row(db, node_id)
|
|
db.execute("SELECT * FROM nodes WHERE node_id = ?", [node_id]).first
|
|
end
|
|
|
|
# Node ids the node list serves.
|
|
#
|
|
# @return [Array<String>] node ids returned by +query_nodes+.
|
|
def listed_ids
|
|
dp.query_nodes(100).map { |node| node["node_id"] }
|
|
end
|
|
|
|
# Unix seconds of keyed evidence old enough to count as positively stale.
|
|
#
|
|
# @return [Integer] a time just past the evidence window.
|
|
def stale_time
|
|
now - PotatoMesh::Config.four_weeks_seconds - 60
|
|
end
|
|
|
|
describe "key-bound identity" do
|
|
let(:id) { "!b2b2b2b2" }
|
|
let(:k1) { Base64.strict_encode64("\x11".b * 32) }
|
|
let(:k2) { Base64.strict_encode64("\x22".b * 32) }
|
|
|
|
# Store the row's own NodeInfo under key +k1+.
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param long_name [String] the owner's long name.
|
|
# @param heard [Integer] unix seconds of the record.
|
|
# @return [void]
|
|
def seed_owner(db, long_name: "Owner", heard: now - 600)
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0xb2b2b2b2,
|
|
"lastHeard" => heard,
|
|
"user" => { "id" => id, "longName" => long_name, "shortName" => "OWNR", "role" => "ROUTER",
|
|
"hwModel" => "RAK4631", "publicKey" => k1 },
|
|
"position" => { "latitude" => 52.52, "longitude" => 13.405, "time" => heard },
|
|
})
|
|
end
|
|
|
|
# Post a NodeInfo for the same id, under +key+ (nil: no key).
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param key [String, nil] public key the record carries.
|
|
# @param long_name [String] long name the record carries.
|
|
# @param heard [Integer] unix seconds of the record (default: now).
|
|
# @return [void]
|
|
def post_profile(db, key:, long_name: "Mallory", heard: now)
|
|
user = { "id" => id, "longName" => long_name, "shortName" => "MLRY", "role" => "CLIENT", "hwModel" => "TBEAM" }
|
|
user["publicKey"] = key if key
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0xb2b2b2b2,
|
|
"lastHeard" => heard,
|
|
"user" => user,
|
|
"deviceMetrics" => { "batteryLevel" => 42 },
|
|
"position" => { "latitude" => 48.137, "longitude" => 11.575, "time" => heard },
|
|
})
|
|
end
|
|
|
|
it "keeps names, role, hardware model, key and position against a record under another key" do
|
|
db = open_db
|
|
seed_owner(db)
|
|
post_profile(db, key: k2)
|
|
row = node_row(db, id)
|
|
db.close
|
|
# /metrics reads this row (SPEC PG2), so the record's profile and
|
|
# position reach no metric; its telemetry does.
|
|
expect(row.slice("long_name", "short_name", "role", "hw_model", "public_key", "latitude", "last_advert_heard")).to eq(
|
|
"long_name" => "Owner", "short_name" => "OWNR", "role" => "ROUTER", "hw_model" => "RAK4631",
|
|
"public_key" => k1, "latitude" => 52.52, "last_advert_heard" => now - 600,
|
|
)
|
|
# Liveness and telemetry still follow the newer record.
|
|
expect(row.slice("last_heard", "battery_level")).to eq("last_heard" => now, "battery_level" => 42.0)
|
|
end
|
|
|
|
it "does not let a record under another key remove the opt-out marker" do
|
|
db = open_db
|
|
seed_owner(db, long_name: "Owner #{marker}")
|
|
db.close
|
|
expect(listed_ids).not_to include(id)
|
|
db = open_db
|
|
post_profile(db, key: k2, long_name: "Owner")
|
|
db.close
|
|
expect(listed_ids).not_to include(id)
|
|
end
|
|
|
|
it "does not let a record under another key add the opt-out marker" do
|
|
db = open_db
|
|
seed_owner(db)
|
|
db.close
|
|
expect(listed_ids).to include(id)
|
|
db = open_db
|
|
post_profile(db, key: k2, long_name: "Owner #{marker}")
|
|
db.close
|
|
expect(listed_ids).to include(id)
|
|
end
|
|
|
|
it "counts a record without a key as another key" do
|
|
db = open_db
|
|
seed_owner(db)
|
|
post_profile(db, key: nil)
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("long_name", "role", "public_key", "latitude")).to eq(
|
|
"long_name" => "Owner", "role" => "ROUTER", "public_key" => k1, "latitude" => 52.52,
|
|
)
|
|
end
|
|
|
|
it "lets a new key take the row over once the stored key's row is positively stale" do
|
|
db = open_db
|
|
seed_owner(db, heard: stale_time)
|
|
post_profile(db, key: k2)
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("long_name", "role", "public_key", "latitude", "last_advert_heard")).to eq(
|
|
"long_name" => "Mallory", "role" => "CLIENT", "public_key" => k2, "latitude" => 48.137,
|
|
"last_advert_heard" => now,
|
|
)
|
|
end
|
|
|
|
it "still renames the node from a record under the stored key" do
|
|
db = open_db
|
|
seed_owner(db)
|
|
post_profile(db, key: k1, long_name: "Owner Renamed")
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("long_name", "role", "latitude", "last_advert_heard")).to eq(
|
|
"long_name" => "Owner Renamed", "role" => "CLIENT", "latitude" => 48.137, "last_advert_heard" => now,
|
|
)
|
|
end
|
|
|
|
it "lets a takeover record older than the row's last_heard stamp no evidence, so a newer one takes over" do
|
|
db = open_db
|
|
seed_owner(db, heard: stale_time)
|
|
# A message touch keeps the row's last_heard ahead of the next record.
|
|
dp.touch_node_last_seen(db, id, nil, rx_time: now - 100, source: :message)
|
|
post_profile(db, key: k2, heard: now - 200)
|
|
after_older = node_row(db, id).slice("long_name", "public_key", "last_advert_heard")
|
|
post_profile(db, key: k2)
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(after_older).to eq("long_name" => "Owner", "public_key" => k1, "last_advert_heard" => stale_time)
|
|
expect(row.slice("long_name", "public_key", "last_advert_heard")).to eq(
|
|
"long_name" => "Mallory", "public_key" => k2, "last_advert_heard" => now,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "same prefix" do
|
|
let(:id) { "!aabbccdd" }
|
|
|
|
# A MeshCore record (contact or advert) under +key+.
|
|
#
|
|
# @param name [String] advertised name.
|
|
# @param key [String] 64-hex public key.
|
|
# @param heard [Integer] unix seconds of the record.
|
|
# @param role [String] MeshCore role.
|
|
# @param position [Hash, nil] advertised position.
|
|
# @return [Hash] node record.
|
|
def meshcore_record(name, key, heard, role: "COMPANION", position: nil)
|
|
record = {
|
|
"lastHeard" => heard,
|
|
"protocol" => "meshcore",
|
|
"user" => { "longName" => name, "shortName" => "aabb", "publicKey" => key, "role" => role },
|
|
}
|
|
record["position"] = position if position
|
|
record
|
|
end
|
|
|
|
let(:k1) { "aabbccdd" + "11" * 28 }
|
|
let(:k2) { "aabbccdd" + "22" * 28 }
|
|
|
|
it "does not let a second MeshCore key with the same prefix rename, re-key or move the first" do
|
|
berlin = { "latitude" => 52.52, "longitude" => 13.405, "time" => now - 600 }
|
|
munich = { "latitude" => 48.137, "longitude" => 11.575, "time" => now }
|
|
db = open_db
|
|
dp.upsert_node(db, id, meshcore_record("Alice", k1, now - 600, position: berlin), protocol: "meshcore")
|
|
dp.upsert_node(db, id, meshcore_record("Mallory", k2, now, role: "REPEATER", position: munich), protocol: "meshcore")
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("long_name", "role", "public_key", "last_advert_heard", "latitude")).to eq(
|
|
"long_name" => "Alice", "role" => "COMPANION", "public_key" => k1,
|
|
"last_advert_heard" => now - 600, "latitude" => 52.52,
|
|
)
|
|
end
|
|
|
|
it "does not let the position row of an advert under a colliding key move the first identity's node" do
|
|
berlin = { "latitude" => 52.52, "longitude" => 13.405, "time" => now - 600 }
|
|
db = open_db
|
|
dp.upsert_node(db, id, meshcore_record("Alice", k1, now - 600, position: berlin), protocol: "meshcore")
|
|
# The rows _store_meshcore_position posts for an advert, under each key.
|
|
position_row = lambda do |key, latitude, longitude, heard|
|
|
{ "id" => heard, "rx_time" => heard, "node_id" => id, "from_id" => id, "latitude" => latitude,
|
|
"longitude" => longitude, "position_time" => heard, "protocol" => "meshcore", "public_key" => key }
|
|
end
|
|
dp.insert_position(db, position_row.call(k2, 48.137, 11.575, now - 60))
|
|
collided = node_row(db, id).slice("latitude", "longitude", "last_heard")
|
|
dp.insert_position(db, position_row.call(k1, 50.11, 8.682, now))
|
|
moved = node_row(db, id).slice("latitude", "longitude")
|
|
positions = db.get_first_value("SELECT COUNT(*) FROM positions WHERE node_id = ?", [id])
|
|
db.close
|
|
expect(collided).to eq("latitude" => 52.52, "longitude" => 13.405, "last_heard" => now - 60)
|
|
expect(moved).to eq("latitude" => 50.11, "longitude" => 8.682)
|
|
expect(positions).to eq(2)
|
|
end
|
|
|
|
it "does not let a colliding MeshCore key pull a same-name chat placeholder's messages onto the row" do
|
|
placeholder = "!5e5e5e5e"
|
|
db = open_db
|
|
dp.upsert_node(db, id, meshcore_record("Alice", k1, now - 600), protocol: "meshcore")
|
|
dp.upsert_node(db, placeholder, {
|
|
"lastHeard" => now - 300,
|
|
"protocol" => "meshcore",
|
|
"user" => { "longName" => "Mallory", "shortName" => "", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
db.execute(
|
|
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text, protocol) VALUES (?,?,?,?,?,?,?,?)",
|
|
[777, now - 300, Time.at(now - 300).utc.iso8601, placeholder, "^all", 0, "Mallory: send me your location", "meshcore"],
|
|
)
|
|
# A colliding advert named after the chat persona, then the row's own
|
|
# advert under the first key.
|
|
dp.upsert_node(db, id, meshcore_record("Mallory", k2, now - 60), protocol: "meshcore")
|
|
dp.upsert_node(db, id, meshcore_record("Alice", k1, now), protocol: "meshcore")
|
|
author = db.get_first_value("SELECT from_id FROM messages WHERE id = 777")
|
|
placeholder_left = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = ?", [placeholder])
|
|
db.close
|
|
expect([author, placeholder_left]).to eq([placeholder, 1])
|
|
end
|
|
|
|
it "does not let a second Reticulum identity with the same prefix take the display name or add a destination" do
|
|
h1 = "aabbccdd" + "11" * 12
|
|
h2 = "aabbccdd" + "22" * 12
|
|
db = open_db
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now,
|
|
"identityHash" => h1,
|
|
"destination" => { "id" => "d1" * 16, "aspect" => "lxmf.delivery", "role" => "PEER" },
|
|
"user" => { "longName" => "Alice Peer", "shortName" => "aabb", "publicKey" => "11" * 64, "role" => "PEER" },
|
|
}, protocol: "reticulum")
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now - 3600,
|
|
"identityHash" => h2,
|
|
"destination" => { "id" => "d2" * 16, "aspect" => "nomadnetwork.node", "role" => "NODE" },
|
|
"user" => { "longName" => "Mallory Node", "shortName" => "aabb", "publicKey" => "22" * 64, "role" => "NODE" },
|
|
}, protocol: "reticulum")
|
|
row = node_row(db, id)
|
|
destinations = db.execute("SELECT id FROM destinations WHERE node_id = ? ORDER BY id", [id]).map { |d| d["id"] }
|
|
db.close
|
|
expect([row["long_name"], row["role"], row["identity_hash"], row["public_key"], destinations]).to eq(
|
|
["Alice Peer", "PEER", h1, "11" * 64, ["d1" * 16]],
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "Reticulum identity binding" do
|
|
let(:id) { "!aabbccdd" }
|
|
|
|
it "keeps the bound identity's names and position against a record without an identity hash" do
|
|
db = open_db
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now - 600,
|
|
"identityHash" => "aabbccdd" + "11" * 12,
|
|
"destination" => { "id" => "d1" * 16, "aspect" => "nomadnetwork.node", "role" => "NODE" },
|
|
"user" => { "longName" => "Bound Node", "shortName" => "aabb", "publicKey" => "11" * 64, "role" => "NODE" },
|
|
"position" => { "latitude" => 52.52, "longitude" => 13.405, "time" => now - 600 },
|
|
}, protocol: "reticulum")
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Other Name", "shortName" => "aabb", "role" => "PEER" },
|
|
"position" => { "latitude" => 48.137, "longitude" => 11.575, "time" => now },
|
|
}, protocol: "reticulum")
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("long_name", "role", "latitude", "last_heard")).to eq(
|
|
"long_name" => "Bound Node", "role" => "NODE", "latitude" => 52.52, "last_heard" => now,
|
|
)
|
|
end
|
|
|
|
it "names the node from the new identity's destinations once it takes a stale row over" do
|
|
h1 = "aabbccdd" + "11" * 12
|
|
h2 = "aabbccdd" + "22" * 12
|
|
db = open_db
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => stale_time,
|
|
"identityHash" => h1,
|
|
"destination" => { "id" => "d1" * 16, "aspect" => "nomadnetwork.node", "role" => "NODE" },
|
|
"user" => { "longName" => "Retired Node", "shortName" => "aabb", "publicKey" => "11" * 64, "role" => "NODE" },
|
|
}, protocol: "reticulum")
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now,
|
|
"identityHash" => h2,
|
|
"destination" => { "id" => "d2" * 16, "aspect" => "lxmf.delivery", "role" => "PEER" },
|
|
"user" => { "longName" => "New Peer", "shortName" => "aabb", "publicKey" => "22" * 64, "role" => "PEER" },
|
|
}, protocol: "reticulum")
|
|
row = node_row(db, id)
|
|
db.close
|
|
# The retired identity's NODE destination outranks a PEER one, but it
|
|
# is no longer this row's identity.
|
|
expect(row.slice("long_name", "role", "identity_hash")).to eq(
|
|
"long_name" => "New Peer", "role" => "PEER", "identity_hash" => h2,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "cross-protocol reclaim (#747)" do
|
|
it "does not let a MeshCore record rename or reclaim a keyed Meshtastic row" do
|
|
id = "!aabbccdd"
|
|
db = open_db
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0xaabbccdd,
|
|
"lastHeard" => now - 600,
|
|
"user" => { "id" => id, "longName" => "Meshtastic Owner", "shortName" => "MTO", "role" => "ROUTER",
|
|
"publicKey" => Base64.strict_encode64("\x11".b * 32) },
|
|
})
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now,
|
|
"protocol" => "meshcore",
|
|
"user" => { "longName" => "Mallory", "shortName" => "aabb", "publicKey" => "aabbccdd" + "22" * 28 },
|
|
}, protocol: "meshcore")
|
|
row = node_row(db, id)
|
|
db.close
|
|
expect(row.slice("protocol", "long_name", "role")).to eq(
|
|
"protocol" => "meshtastic", "long_name" => "Meshtastic Owner", "role" => "ROUTER",
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "node number" do
|
|
it "keeps a node's own number against a record whose num names another node" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!b2b2b2b2", {
|
|
"num" => 0xb2b2b2b2, "lastHeard" => now - 60,
|
|
"user" => { "id" => "!b2b2b2b2", "longName" => "Bob", "shortName" => "BOB" },
|
|
})
|
|
dp.upsert_node(db, "!a1a1a1a1", {
|
|
"num" => 0xb2b2b2b2, "lastHeard" => now,
|
|
"user" => { "id" => "!a1a1a1a1", "longName" => "Sender", "shortName" => "SNDR" },
|
|
})
|
|
rows = db.execute("SELECT node_id, num FROM nodes ORDER BY node_id").map { |r| [r["node_id"], r["num"]] }
|
|
db.close
|
|
expect(rows).to eq([["!a1a1a1a1", 0xa1a1a1a1], ["!b2b2b2b2", 0xb2b2b2b2]])
|
|
end
|
|
end
|
|
|
|
describe "decrypted records" do
|
|
# Store a payload the web app decrypted from a message sent by +!a1a1a1a1+.
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param portnum [Integer] port number of the decrypted payload.
|
|
# @param decoded [Hash] what the payload decoder returns for it.
|
|
# @param from_id [String, nil] sender of the message; nil for none.
|
|
# @return [Boolean] whether the payload was stored.
|
|
def store_decrypted(db, portnum, decoded, from_id: "!a1a1a1a1")
|
|
allow(PotatoMesh::App::Meshtastic::PayloadDecoder).to receive(:decode).and_return(decoded)
|
|
message = from_id ? { "from_num" => 0xa1a1a1a1 } : {}
|
|
dp.store_decrypted_payload(
|
|
db, message, 777_001, { payload: "decrypted".b, portnum: portnum },
|
|
rx_time: now, rx_iso: Time.at(now).utc.iso8601, from_id: from_id, to_id: "^all",
|
|
channel: 0, portnum: portnum, hop_limit: 3, snr: 5.0, rssi: -80,
|
|
)
|
|
end
|
|
|
|
it "drops a decrypted NodeInfo whose user.id names another node" do
|
|
allow(dp).to receive(:warn_log)
|
|
db = open_db
|
|
stored = store_decrypted(db, 4, {
|
|
"type" => "NODEINFO_APP",
|
|
"payload" => { "user" => { "id" => "!b2b2b2b2", "long_name" => "Mallory", "short_name" => "MLRY" } },
|
|
})
|
|
count = db.get_first_value("SELECT COUNT(*) FROM nodes")
|
|
db.close
|
|
expect([stored, count]).to eq([false, 0])
|
|
expect(dp).to have_received(:warn_log).with(
|
|
"Dropped decrypted payload naming another node",
|
|
hash_including(type: "NODEINFO_APP", from_id: "!a1a1a1a1", node_id: "!b2b2b2b2"),
|
|
)
|
|
end
|
|
|
|
it "files a decrypted NodeInfo under its sender's own number" do
|
|
db = open_db
|
|
stored = store_decrypted(db, 4, {
|
|
"type" => "NODEINFO_APP",
|
|
"payload" => { "num" => 0xb2b2b2b2, "user" => { "id" => "!a1a1a1a1", "long_name" => "Sender", "short_name" => "SNDR" } },
|
|
})
|
|
rows = db.execute("SELECT node_id, num, long_name FROM nodes").map { |r| r.values_at("node_id", "num", "long_name") }
|
|
db.close
|
|
expect([stored, rows]).to eq([true, [["!a1a1a1a1", 0xa1a1a1a1, "Sender"]]])
|
|
end
|
|
|
|
it "files a decrypted NodeInfo that names no id under its sender" do
|
|
db = open_db
|
|
stored = store_decrypted(db, 4, {
|
|
"type" => "NODEINFO_APP",
|
|
"payload" => { "num" => 0xb2b2b2b2, "user" => { "long_name" => "No Id", "short_name" => "NOID" } },
|
|
})
|
|
rows = db.execute("SELECT node_id, num, long_name FROM nodes").map { |r| r.values_at("node_id", "num", "long_name") }
|
|
db.close
|
|
expect([stored, rows]).to eq([true, [["!a1a1a1a1", 0xa1a1a1a1, "No Id"]]])
|
|
end
|
|
|
|
it "files a decrypted NodeInfo with no sender under the id it names" do
|
|
db = open_db
|
|
stored = store_decrypted(db, 4, {
|
|
"type" => "NODEINFO_APP",
|
|
"payload" => { "id" => "!b2b2b2b2", "num" => 0xb2b2b2b2, "user" => { "id" => "!b2b2b2b2", "long_name" => "Unsent", "short_name" => "UNST" } },
|
|
}, from_id: nil)
|
|
rows = db.execute("SELECT node_id, num, long_name FROM nodes").map { |r| r.values_at("node_id", "num", "long_name") }
|
|
db.close
|
|
expect([stored, rows]).to eq([true, [["!b2b2b2b2", 0xb2b2b2b2, "Unsent"]]])
|
|
end
|
|
|
|
it "drops a decrypted NodeInfo whose own id or user.id is not the sender's canonical id" do
|
|
allow(dp).to receive(:warn_log)
|
|
db = open_db
|
|
stored = [
|
|
{ "id" => "!Decrypted", "user" => { "long_name" => "Decoded" } },
|
|
{ "user" => { "id" => "!Decrypted", "long_name" => "Decoded" } },
|
|
{ "user" => { "id" => "!A1A1A1A1", "long_name" => "Decoded" } },
|
|
].map { |payload| store_decrypted(db, 4, { "type" => "NODEINFO_APP", "payload" => payload }) }
|
|
count = db.get_first_value("SELECT COUNT(*) FROM nodes")
|
|
db.close
|
|
expect([stored, count]).to eq([[false, false, false], 0])
|
|
%w[!Decrypted !A1A1A1A1].each do |claimed|
|
|
expect(dp).to have_received(:warn_log).with(
|
|
"Dropped decrypted payload naming another node",
|
|
hash_including(type: "NODEINFO_APP", from_id: "!a1a1a1a1", node_id: claimed),
|
|
).at_least(:once)
|
|
end
|
|
end
|
|
|
|
it "drops a decrypted NeighborInfo naming another node" do
|
|
allow(dp).to receive(:warn_log)
|
|
db = open_db
|
|
stored = store_decrypted(db, 71, {
|
|
"type" => "NEIGHBORINFO_APP",
|
|
"payload" => { "node_id" => 0xb2b2b2b2, "neighbors" => [{ "node_id" => 0xc3c3c3c3, "snr" => 5.0 }] },
|
|
})
|
|
count = db.get_first_value("SELECT COUNT(*) FROM neighbors")
|
|
db.close
|
|
expect([stored, count]).to eq([false, 0])
|
|
expect(dp).to have_received(:warn_log).with(
|
|
"Dropped decrypted payload naming another node",
|
|
hash_including(type: "NEIGHBORINFO_APP", from_id: "!a1a1a1a1", node_id: "!b2b2b2b2"),
|
|
)
|
|
end
|
|
|
|
it "drops a decrypted NeighborInfo whose node_id is not the sender's canonical id" do
|
|
allow(dp).to receive(:warn_log)
|
|
db = open_db
|
|
stored = ["!Decrypted", "!A1A1A1A1", -1].map do |claimed|
|
|
store_decrypted(db, 71, {
|
|
"type" => "NEIGHBORINFO_APP",
|
|
"payload" => { "node_id" => claimed, "neighbors" => [{ "node_id" => 0xc3c3c3c3, "snr" => 5.0 }] },
|
|
})
|
|
end
|
|
own = store_decrypted(db, 71, {
|
|
"type" => "NEIGHBORINFO_APP",
|
|
"payload" => { "node_id" => 0xa1a1a1a1, "neighbors" => [{ "node_id" => 0xc3c3c3c3, "snr" => 5.0 }] },
|
|
})
|
|
rows = db.execute("SELECT node_id, neighbor_id FROM neighbors").map { |r| r.values_at("node_id", "neighbor_id") }
|
|
db.close
|
|
expect([stored, own, rows]).to eq([[false, false, false], true, [["!a1a1a1a1", "!c3c3c3c3"]]])
|
|
expect(dp).to have_received(:warn_log).with(
|
|
"Dropped decrypted payload naming another node",
|
|
hash_including(type: "NEIGHBORINFO_APP", from_id: "!a1a1a1a1", node_id: -1),
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "placeholder role" do
|
|
let(:id) { "!0b6f0004" }
|
|
|
|
# Store a position from an unknown node, which creates its placeholder
|
|
# row (CLIENT_HIDDEN for Meshtastic, the base role for other protocols).
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param protocol [String] protocol stamped on the position.
|
|
# @return [void]
|
|
def hear_position(db, protocol: "meshtastic")
|
|
dp.insert_position(db, {
|
|
"id" => 965_001, "rx_time" => now - 20, "node_id" => id, "latitude" => 52.5, "longitude" => 13.4,
|
|
"protocol" => protocol,
|
|
})
|
|
end
|
|
|
|
# Read the stored role.
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @return [String, nil] the node's role.
|
|
def stored_role(db)
|
|
db.get_first_value("SELECT role FROM nodes WHERE node_id = ?", [id])
|
|
end
|
|
|
|
it "replaces the CLIENT_HIDDEN placeholder when a Meshtastic record carries a user but no role" do
|
|
db = open_db
|
|
hear_position(db)
|
|
placeholder_role = stored_role(db)
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0x0b6f0004, "lastHeard" => now - 10,
|
|
"user" => { "id" => id, "shortName" => "PLC", "longName" => "Plain Client", "hwModel" => "TBEAM" },
|
|
})
|
|
role = stored_role(db)
|
|
db.close
|
|
expect([placeholder_role, role]).to eq(%w[CLIENT_HIDDEN CLIENT])
|
|
end
|
|
|
|
it "keeps a CLIENT_HIDDEN role the record states" do
|
|
db = open_db
|
|
hear_position(db)
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0x0b6f0004, "lastHeard" => now - 10,
|
|
"user" => { "id" => id, "shortName" => "HID", "longName" => "Hidden Client", "role" => "CLIENT_HIDDEN" },
|
|
})
|
|
role = stored_role(db)
|
|
db.close
|
|
expect(role).to eq("CLIENT_HIDDEN")
|
|
end
|
|
|
|
it "keeps the placeholder role against the meshtastic library's stand-in for a node it has no NodeInfo for" do
|
|
db = open_db
|
|
hear_position(db)
|
|
# What the library's +_getOrCreateByNum+ files for such a node, and the
|
|
# node-list snapshot then posts.
|
|
dp.upsert_node(db, id, {
|
|
"num" => 0x0b6f0004, "lastHeard" => now - 10,
|
|
"user" => { "id" => id, "longName" => "Meshtastic 0004", "shortName" => "0004", "hwModel" => "UNSET" },
|
|
})
|
|
role = stored_role(db)
|
|
db.close
|
|
expect(role).to eq("CLIENT_HIDDEN")
|
|
end
|
|
|
|
it "keeps the stored role when a record carries no user" do
|
|
db = open_db
|
|
hear_position(db)
|
|
dp.upsert_node(db, id, { "num" => 0x0b6f0004, "lastHeard" => now - 10 })
|
|
role = stored_role(db)
|
|
db.close
|
|
expect(role).to eq("CLIENT_HIDDEN")
|
|
end
|
|
|
|
it "keeps another protocol's base role when its record names no role" do
|
|
db = open_db
|
|
hear_position(db, protocol: "meshcore")
|
|
dp.upsert_node(db, id, {
|
|
"lastHeard" => now - 10,
|
|
"user" => { "shortName" => "0b6f", "longName" => "Core Node", "publicKey" => "0b6f0004" + "44" * 28 },
|
|
}, protocol: "meshcore")
|
|
role = stored_role(db)
|
|
db.close
|
|
expect(role).to eq("COMPANION")
|
|
end
|
|
end
|
|
end
|