meshtastic 0.0.183 → 0.0.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/README.md +1 -0
- data/documentation/admin-backup.md +369 -0
- data/documentation/admin.md +4 -0
- data/lib/meshtastic/admin/backup.rb +560 -0
- data/lib/meshtastic/admin.rb +2 -0
- data/lib/meshtastic/config_pb.rb +1 -1
- data/lib/meshtastic/mesh_pb.rb +1 -1
- data/lib/meshtastic/module_config_pb.rb +2 -1
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
- metadata +4 -1
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'meshtastic/admin/backup' if File.exist?(File.expand_path('../../../../lib/meshtastic/admin/backup.rb', __dir__))
|
|
7
|
+
|
|
8
|
+
# Stateful peer decodes actual production Serial ToRadio frames and encodes replies.
|
|
9
|
+
class BackupRadioPeer
|
|
10
|
+
attr_reader :handle, :sent, :state, :packets
|
|
11
|
+
attr_accessor :fail_on, :silent_on, :drop_ack_on
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@sent = []
|
|
15
|
+
@packets = []
|
|
16
|
+
@state = {
|
|
17
|
+
owner: Meshtastic::User.new(id: '!aabbccdd', long_name: 'Backup node', short_name: 'BN', macaddr: 'secret', hw_model: :UNSET),
|
|
18
|
+
device: Meshtastic::Config.new(device: { serial_enabled: false }),
|
|
19
|
+
network: Meshtastic::Config.new(network: { wifi_enabled: true, wifi_psk: 'password' }),
|
|
20
|
+
mqtt: Meshtastic::ModuleConfig.new(mqtt: { enabled: false, password: 'broker-secret' }),
|
|
21
|
+
channel: Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { psk: "\x00\xff".b, name: 'Test' }),
|
|
22
|
+
ui: Meshtastic::DeviceUIConfig.new(calibration_data: "\x00\xff".b)
|
|
23
|
+
}
|
|
24
|
+
@handle = { serial_conn: self, my_node_num: 0xaabbccdd, from_radio_queue: Queue.new }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(bytes)
|
|
28
|
+
raise 'invalid serial frame' unless bytes.byteslice(0, 2) == "\x94\xc3".b && bytes.byteslice(2, 2).unpack1('n') == bytes.bytesize - 4
|
|
29
|
+
|
|
30
|
+
packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
|
|
31
|
+
@packets << packet
|
|
32
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
33
|
+
@sent << message
|
|
34
|
+
variant = message.payload_variant
|
|
35
|
+
return bytes.bytesize if variant == silent_on
|
|
36
|
+
|
|
37
|
+
value = case variant
|
|
38
|
+
when :get_owner_request then state[:owner]
|
|
39
|
+
when :get_config_request then state[message.get_config_request.to_s.delete_suffix('_CONFIG').downcase.to_sym]
|
|
40
|
+
when :get_module_config_request then state.fetch(message.get_module_config_request, state[:mqtt])
|
|
41
|
+
when :get_channel_request then state.fetch(message.get_channel_request - 1, state[:channel])
|
|
42
|
+
when :get_ui_config_request then state[:ui]
|
|
43
|
+
when :get_ringtone_request then state[:ringtone]
|
|
44
|
+
when :get_canned_message_module_messages_request then state[:canned_messages]
|
|
45
|
+
end
|
|
46
|
+
if value
|
|
47
|
+
response = Meshtastic::AdminMessage.new(variant.to_s.sub(/request$/, 'response').to_sym => value, session_passkey: 'passkey!')
|
|
48
|
+
data = Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: packet.id, payload: response.to_proto)
|
|
49
|
+
else
|
|
50
|
+
@state[:owner] = message.set_owner if variant == :set_owner
|
|
51
|
+
@state[message.set_config.payload_variant] = message.set_config if variant == :set_config
|
|
52
|
+
@state[:channel] = message.set_channel if variant == :set_channel
|
|
53
|
+
@state[:ringtone] = message.set_ringtone_message if variant == :set_ringtone_message
|
|
54
|
+
@state[:canned_messages] = message.set_canned_message_module_messages if variant == :set_canned_message_module_messages
|
|
55
|
+
if variant == :set_module_config
|
|
56
|
+
index = Meshtastic::ModuleConfig.descriptor.map(&:name).index(message.set_module_config.payload_variant.to_s)
|
|
57
|
+
@state[Meshtastic::Admin::Backup::MODULE_TYPES[index]] = message.set_module_config
|
|
58
|
+
end
|
|
59
|
+
return bytes.bytesize if variant == drop_ack_on
|
|
60
|
+
|
|
61
|
+
reason = variant == fail_on ? :NOT_AUTHORIZED : :NONE
|
|
62
|
+
data = Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id, payload: Meshtastic::Routing.new(error_reason: reason).to_proto)
|
|
63
|
+
end
|
|
64
|
+
response = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to, decoded: data))
|
|
65
|
+
handle[:from_radio_queue] << Meshtastic::FromRadio.decode(response.to_proto)
|
|
66
|
+
bytes.bytesize
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flush; end
|
|
70
|
+
def closed? = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe 'Meshtastic::Admin::Backup' do
|
|
74
|
+
let(:backup_api) { Meshtastic::Admin.const_get(:Backup) }
|
|
75
|
+
let(:peer) { BackupRadioPeer.new }
|
|
76
|
+
let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
|
|
77
|
+
|
|
78
|
+
it 'exports binary DeviceProfile bytes with fresh values and presence-aware semantic round trips' do
|
|
79
|
+
peer.state[:owner].is_unmessagable = false
|
|
80
|
+
peer.state[:channel].settings.psk = "\x01".b
|
|
81
|
+
peer.state[:lora] = Meshtastic::Config.new(lora: { region: :US })
|
|
82
|
+
result = backup_api.export(options.merge(format: :device_profile, include_ui: false, config_types: %i[DEVICE_CONFIG LORA_CONFIG]))
|
|
83
|
+
expect(result).to include(status: :exported, count: 5, format: :device_profile)
|
|
84
|
+
expect(result[:backup].encoding).to eq(Encoding::BINARY)
|
|
85
|
+
profile = Meshtastic::DeviceProfile.decode(result[:backup])
|
|
86
|
+
expect(profile.long_name).to eq(peer.state[:owner].long_name)
|
|
87
|
+
expect(profile.has_is_licensed?).to be_truthy
|
|
88
|
+
expect(profile.is_licensed).to be(false)
|
|
89
|
+
expect(profile.has_is_unmessagable?).to be_truthy
|
|
90
|
+
expect(profile.is_unmessagable).to be(false)
|
|
91
|
+
expect(profile.config.device).to eq(peer.state[:device].device)
|
|
92
|
+
expect(profile.module_config.mqtt).to eq(peer.state[:mqtt].mqtt)
|
|
93
|
+
channels = Meshtastic::Admin::Channel.import_url(url: profile.channel_url)
|
|
94
|
+
expect(channels.settings.first).to eq(peer.state[:channel].settings)
|
|
95
|
+
expect(channels.lora_config).to eq(profile.config.lora)
|
|
96
|
+
expect(result[:backup]).not_to include('passkey!', '!aabbccdd')
|
|
97
|
+
peer.sent.clear
|
|
98
|
+
plan = backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)
|
|
99
|
+
expect(plan[:planned]).to eq(result[:count])
|
|
100
|
+
expect(peer.sent).to be_empty
|
|
101
|
+
restored = backup_api.import(transport_obj: peer.handle, backup: result[:backup], verify: true, timeout: 0.3)
|
|
102
|
+
expect(restored).to include(status: :readback_matched, acknowledged: 5)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'rejects incompatible binary selections before any requests or file creation' do
|
|
106
|
+
[{ include_ui: true }, { channel_indexes: [1] }, { channel_indexes: [1, 0] },
|
|
107
|
+
{ format: :auto }, { format: nil }, { format: :unknown },
|
|
108
|
+
{ config_types: [], module_config_types: [], channel_indexes: [], include_owner: false }].each do |invalid|
|
|
109
|
+
expect { backup_api.export(options.merge(format: :device_profile, include_ui: false).merge(invalid)) }.to raise_error(ArgumentError)
|
|
110
|
+
expect(peer.sent).to be_empty
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'writes exclusive binary 0600 files and rejects unrepresentable returned channels' do
|
|
115
|
+
peer.state[:channel].settings.psk = "\x01".b
|
|
116
|
+
Dir.mktmpdir do |directory|
|
|
117
|
+
path = File.join(directory, 'backup.cfg')
|
|
118
|
+
opts = options.merge(format: :device_profile, include_ui: false, path: path)
|
|
119
|
+
result = backup_api.export(opts)
|
|
120
|
+
expect(File.stat(path).mode & 0o777).to eq(0o600)
|
|
121
|
+
expect(File.binread(path)).to eq(result[:backup])
|
|
122
|
+
expect { backup_api.export(opts) }.to raise_error(Errno::EEXIST)
|
|
123
|
+
link = File.join(directory, 'link.cfg')
|
|
124
|
+
File.symlink(path, link)
|
|
125
|
+
expect { backup_api.export(opts.merge(path: link)) }.to raise_error(SystemCallError)
|
|
126
|
+
peer.sent.clear
|
|
127
|
+
expect(backup_api.import(transport_obj: peer.handle, path: path, dry_run: true)[:planned]).to eq(result[:count])
|
|
128
|
+
expect(peer.sent).to be_empty
|
|
129
|
+
profile = Meshtastic::DeviceProfile.decode(result[:backup])
|
|
130
|
+
expect(profile.has_is_unmessagable?).to be_falsey
|
|
131
|
+
expect(profile.has_fixed_position?).to be_falsey
|
|
132
|
+
expect(profile.has_ringtone?).to be_falsey
|
|
133
|
+
expect(profile.has_canned_messages?).to be_falsey
|
|
134
|
+
expect(Meshtastic::Admin::Channel.import_url(url: profile.channel_url).lora_config).to be_nil
|
|
135
|
+
peer.state[:channel].role = :DISABLED
|
|
136
|
+
expect { backup_api.export(opts.merge(path: File.join(directory, 'bad.cfg'))) }.to raise_error(ArgumentError, /round-trip/)
|
|
137
|
+
expect(File.exist?(File.join(directory, 'bad.cfg'))).to be(false)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'exports explicit fixed position and fresh optional strings without losing zero or empty presence' do
|
|
142
|
+
peer.state[:ringtone] = ''
|
|
143
|
+
peer.state[:canned_messages] = ''
|
|
144
|
+
opts = options.merge(format: :device_profile, include_owner: false, include_ui: false, config_types: [], module_config_types: [], channel_indexes: [],
|
|
145
|
+
fixed_position: { latitude_i: 0, longitude_i: 0, altitude: 0 }, include_ringtone: true, include_canned_messages: true)
|
|
146
|
+
result = backup_api.export(opts)
|
|
147
|
+
expect(result[:count]).to eq(3)
|
|
148
|
+
profile = Meshtastic::DeviceProfile.decode(result[:backup])
|
|
149
|
+
expect(profile.fixed_position).to eq(Meshtastic::Position.new(opts[:fixed_position]))
|
|
150
|
+
expect(profile.has_ringtone?).to be_truthy
|
|
151
|
+
expect(profile.ringtone).to eq('')
|
|
152
|
+
expect(profile.has_canned_messages?).to be_truthy
|
|
153
|
+
expect(profile.canned_messages).to eq('')
|
|
154
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[get_ringtone_request get_canned_message_module_messages_request])
|
|
155
|
+
peer.sent.clear
|
|
156
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(3)
|
|
157
|
+
expect(peer.sent).to be_empty
|
|
158
|
+
[nil, 'bad', { unknown: 1 }].each do |position|
|
|
159
|
+
expect { backup_api.export(opts.merge(fixed_position: position)) }.to raise_error(ArgumentError)
|
|
160
|
+
expect(peer.sent).to be_empty
|
|
161
|
+
end
|
|
162
|
+
%i[fixed_position include_ringtone include_canned_messages].each do |key|
|
|
163
|
+
expect { backup_api.export(options.merge(key => opts[key])) }.to raise_error(ArgumentError)
|
|
164
|
+
expect(peer.sent).to be_empty
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'exports all core and module sections with present empty messages' do
|
|
169
|
+
Meshtastic::Config.descriptor.to_a.take(8).each do |field|
|
|
170
|
+
peer.state[field.name.to_sym] = Meshtastic::Config.new(field.name.to_sym => {})
|
|
171
|
+
end
|
|
172
|
+
Meshtastic::ModuleConfig.descriptor.each_with_index do |field, index|
|
|
173
|
+
peer.state[backup_api::MODULE_TYPES[index]] = Meshtastic::ModuleConfig.new(field.name.to_sym => {})
|
|
174
|
+
end
|
|
175
|
+
result = backup_api.export(options.merge(format: :device_profile, include_owner: false, include_ui: false, channel_indexes: [],
|
|
176
|
+
config_types: backup_api::CONFIG_TYPES, module_config_types: backup_api::MODULE_TYPES))
|
|
177
|
+
expect(result[:count]).to eq(25)
|
|
178
|
+
profile = Meshtastic::DeviceProfile.decode(result[:backup])
|
|
179
|
+
expect(profile.has_channel_url?).to be_falsey
|
|
180
|
+
expect(profile.has_long_name?).to be_falsey
|
|
181
|
+
Meshtastic::Config.descriptor.to_a.take(8).each { |field| expect(profile.config[field.name]).to eq(peer.state[field.name.to_sym][field.name]) }
|
|
182
|
+
Meshtastic::ModuleConfig.descriptor.each_with_index do |field, index|
|
|
183
|
+
expect(profile.module_config[field.name]).to eq(peer.state[backup_api::MODULE_TYPES[index]][field.name])
|
|
184
|
+
end
|
|
185
|
+
peer.sent.clear
|
|
186
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(25)
|
|
187
|
+
expect(peer.sent).to be_empty
|
|
188
|
+
peer.state[:device] = Meshtastic::Config.new(network: {})
|
|
189
|
+
expect { backup_api.export(options.merge(format: :device_profile, include_ui: false, channel_indexes: [])) }.to raise_error(ArgumentError, /slot/)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'documents the complete export format and selection API in runtime help' do
|
|
193
|
+
expect { backup_api.help }.to output(/Backup.export.*format:.*:json.*:device_profile.*path:.*fixed_position:.*include_ringtone:.*include_canned_messages:.*channel:.*hop_limit:.*Backup.import/m).to_stdout
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'preserves multiple ordered channels and never creates binary files on read timeout' do
|
|
197
|
+
peer.state[:channel].settings.psk = "\x01".b
|
|
198
|
+
peer.state[1] = Meshtastic::Channel.new(index: 1, role: :SECONDARY, settings: { name: 'Second', psk: "\x00".b })
|
|
199
|
+
opts = options.merge(format: :device_profile, include_ui: false, channel_indexes: [0, 1])
|
|
200
|
+
profile = Meshtastic::DeviceProfile.decode(backup_api.export(opts)[:backup])
|
|
201
|
+
expect(Meshtastic::Admin::Channel.import_url(url: profile.channel_url).settings.to_a).to eq([peer.state[:channel].settings, peer.state[1].settings])
|
|
202
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true)[:plan]).to include(section: 'channel', slot: 1)
|
|
203
|
+
peer.silent_on = :get_config_request
|
|
204
|
+
Dir.mktmpdir do |directory|
|
|
205
|
+
path = File.join(directory, 'incomplete.cfg')
|
|
206
|
+
expect { backup_api.export(opts.merge(path: path, timeout: 0.1)) }.to raise_error(Timeout::Error)
|
|
207
|
+
expect(File.exist?(path)).to be(false)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
describe 'Meshtastic::Admin::Backup import and JSON export' do
|
|
213
|
+
let(:backup_api) { Meshtastic::Admin.const_get(:Backup) }
|
|
214
|
+
let(:peer) { BackupRadioPeer.new }
|
|
215
|
+
let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
|
|
216
|
+
|
|
217
|
+
it 'restores every DeviceProfile section, including present empty strings, false flags and zero coordinates' do
|
|
218
|
+
config = Meshtastic::LocalConfig.new(version: 24)
|
|
219
|
+
Meshtastic::Config.descriptor.to_a.take(8).each { |field| config[field.name] = field.subtype.msgclass.new }
|
|
220
|
+
modules = Meshtastic::LocalModuleConfig.new(version: 24)
|
|
221
|
+
Meshtastic::ModuleConfig.descriptor.each { |field| modules[field.name] = field.subtype.msgclass.new }
|
|
222
|
+
config.security = Meshtastic::Config::SecurityConfig.new(private_key: "\x00\xff".b, admin_key: ['synthetic-key'], serial_enabled: false)
|
|
223
|
+
config.lora = Meshtastic::Config::LoRaConfig.new(region: :US, tx_power: -1, frequency_offset: 1.25, ignore_incoming: [1, 0xffffffff])
|
|
224
|
+
modules.mqtt = Meshtastic::ModuleConfig::MQTTConfig.new(enabled: false, password: 'synthetic-password')
|
|
225
|
+
channels = Meshtastic::ChannelSet.new(settings: [{ name: 'test', psk: "\x01".b }], lora_config: config.lora)
|
|
226
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false,
|
|
227
|
+
config: config, module_config: modules, channel_url: "https://meshtastic.org/e/##{Base64.urlsafe_encode64(channels.to_proto, padding: false)}",
|
|
228
|
+
fixed_position: { latitude_i: 0, longitude_i: 0, altitude: 0 }, ringtone: '', canned_messages: '')
|
|
229
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, verify: true, timeout: 0.3)
|
|
230
|
+
expect(result).to include(status: :readback_incomplete, planned: 30, acknowledged: 30, readback_matched: 29)
|
|
231
|
+
expect(result[:records].find { |record| record[:section] == 'fixed_position' }).to include(readback: :unsupported)
|
|
232
|
+
expect(peer.state[:owner]).to eq(Meshtastic::User.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false))
|
|
233
|
+
expect(peer.state[:owner].has_is_unmessagable?).to be_truthy
|
|
234
|
+
expect(peer.sent.find { |message| message.payload_variant == :set_fixed_position }.set_fixed_position).to eq(profile.fixed_position)
|
|
235
|
+
expect(peer.sent.find { |message| message.payload_variant == :set_ringtone_message }.set_ringtone_message).to eq('')
|
|
236
|
+
expect(peer.sent.find { |message| message.payload_variant == :set_canned_message_module_messages }.set_canned_message_module_messages).to eq('')
|
|
237
|
+
expect(peer.state[:channel].settings).to eq(channels.settings.first)
|
|
238
|
+
config.class.descriptor.each do |field|
|
|
239
|
+
next if field.name == 'version'
|
|
240
|
+
|
|
241
|
+
expect(peer.state[field.name.to_sym][field.name]).to eq(config[field.name])
|
|
242
|
+
end
|
|
243
|
+
Meshtastic::ModuleConfig.descriptor.each_with_index do |field, index|
|
|
244
|
+
expect(peer.state[backup_api::MODULE_TYPES[index]][field.name]).to eq(modules[field.name])
|
|
245
|
+
end
|
|
246
|
+
expect(result.inspect).not_to include('synthetic-password', 'synthetic-key')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'imports binary DeviceProfile config presence and scalar defaults without radio requests in dry runs' do
|
|
250
|
+
profile = Meshtastic::DeviceProfile.new(config: { device: { serial_enabled: false }, power: {} }, module_config: { mqtt: { enabled: false } })
|
|
251
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, dry_run: true)
|
|
252
|
+
expect(result[:plan]).to eq([{ section: 'config', slot: 'DEVICE_CONFIG' }, { section: 'config', slot: 'POWER_CONFIG' }, { section: 'module_config', slot: 'MQTT_CONFIG' }])
|
|
253
|
+
expect(peer.sent).to be_empty
|
|
254
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, timeout: 0.3)
|
|
255
|
+
expect(result[:acknowledged]).to eq(3)
|
|
256
|
+
expect(peer.sent.map { |message| message.public_send(message.payload_variant) }).to eq([
|
|
257
|
+
Meshtastic::Config.new(device: { serial_enabled: false }),
|
|
258
|
+
Meshtastic::Config.new(power: {}),
|
|
259
|
+
Meshtastic::ModuleConfig.new(mqtt: { enabled: false })
|
|
260
|
+
])
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it 'validates and dry-runs a complete document without any radio writes' do
|
|
264
|
+
document = backup_api.export(options)[:backup]
|
|
265
|
+
peer.sent.clear
|
|
266
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, dry_run: true)
|
|
267
|
+
expect(result).to include(status: :dry_run, planned: 5, acknowledged: 0, persistence_verified: false)
|
|
268
|
+
expect(result[:plan]).to eq([
|
|
269
|
+
{ section: 'owner', slot: nil }, { section: 'config', slot: 'DEVICE_CONFIG' },
|
|
270
|
+
{ section: 'module_config', slot: 'MQTT_CONFIG' }, { section: 'ui', slot: nil }, { section: 'channel', slot: 0 }
|
|
271
|
+
])
|
|
272
|
+
expect(peer.sent).to be_empty
|
|
273
|
+
invalid = Marshal.load(Marshal.dump(document))
|
|
274
|
+
invalid['records'].last['value']['calibration_data'] = '%%%'
|
|
275
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: invalid) }.to raise_error(ArgumentError)
|
|
276
|
+
expect(peer.sent).to be_empty
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'restores exact protobuf values with disruptive settings last and explicit edit ACKs' do
|
|
280
|
+
document = backup_api.export(options.merge(config_types: %i[NETWORK_CONFIG DEVICE_CONFIG]))[:backup]
|
|
281
|
+
peer.sent.clear
|
|
282
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true, timeout: 0.3)
|
|
283
|
+
expect(result).to include(status: :acknowledged, planned: 6, acknowledged: 6, persistence_verified: false, transaction: :commit_acknowledged)
|
|
284
|
+
expect(peer.sent.first.payload_variant).to eq(:begin_edit_settings)
|
|
285
|
+
expect(peer.sent.last.payload_variant).to eq(:commit_edit_settings)
|
|
286
|
+
expect(peer.sent[-2].set_config.payload_variant).to eq(:network)
|
|
287
|
+
expect(peer.sent.find { |m| m.payload_variant == :set_channel }.set_channel).to eq(peer.state[:channel])
|
|
288
|
+
expect(peer.sent.find { |m| m.payload_variant == :store_ui_config }.store_ui_config).to eq(peer.state[:ui])
|
|
289
|
+
expect(peer.state[:owner].id).to eq('')
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it 'stops on a routing failure without commit or replay and reports acknowledged versus uncertain records' do
|
|
293
|
+
document = backup_api.export(options)[:backup]
|
|
294
|
+
peer.sent.clear
|
|
295
|
+
peer.fail_on = :set_module_config
|
|
296
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true, timeout: 0.3)
|
|
297
|
+
expect(result).to include(status: :partial_failure, acknowledged: 2, attempted: 3, transaction: :open)
|
|
298
|
+
expect(result[:failure]).to include(section: 'module_config', error: 'Meshtastic::Admin::RoutingError', reason: :NOT_AUTHORIZED)
|
|
299
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_owner set_config set_module_config])
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'continues after a lost MQTT write ACK only on a fresh matching read without replay' do
|
|
303
|
+
document = backup_api.export(options)[:backup]
|
|
304
|
+
peer.sent.clear
|
|
305
|
+
peer.drop_ack_on = :set_module_config
|
|
306
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true, timeout: 0.15)
|
|
307
|
+
expect(result).to include(status: :applied, acknowledged: 4, readback_confirmed: 1, attempted: 5,
|
|
308
|
+
persistence_verified: false, transaction: :commit_acknowledged)
|
|
309
|
+
expect(result[:records][2]).to include(section: 'module_config', slot: 'MQTT_CONFIG', status: :readback_confirmed)
|
|
310
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_owner set_config set_module_config get_module_config_request store_ui_config set_channel commit_edit_settings])
|
|
311
|
+
expect(result.inspect).not_to include('broker-secret', 'passkey!')
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it 'reports a missing commit ACK as uncertain without replaying' do
|
|
315
|
+
document = backup_api.export(options)[:backup]
|
|
316
|
+
peer.sent.clear
|
|
317
|
+
peer.silent_on = :commit_edit_settings
|
|
318
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true, timeout: 0.1)
|
|
319
|
+
expect(result).to include(status: :partial_failure, acknowledged: 5, transaction: :commit_uncertain)
|
|
320
|
+
expect(result[:failure]).to include(operation: :commit_edit_settings, error: 'Timeout::Error')
|
|
321
|
+
expect(peer.sent.count { |m| m.payload_variant == :commit_edit_settings }).to eq(1)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
it 'writes exclusive 0600 JSON files and accepts only one import source, rejecting symlinks' do
|
|
325
|
+
Dir.mktmpdir do |directory|
|
|
326
|
+
path = File.join(directory, 'backup.json')
|
|
327
|
+
result = backup_api.export(options.merge(path: path))
|
|
328
|
+
expect(File.stat(path).mode & 0o777).to eq(0o600)
|
|
329
|
+
expect(JSON.parse(File.read(path))).to eq(result[:backup])
|
|
330
|
+
expect { backup_api.export(options.merge(path: path)) }.to raise_error(Errno::EEXIST)
|
|
331
|
+
link = File.join(directory, 'link.json')
|
|
332
|
+
File.symlink(path, link)
|
|
333
|
+
expect { backup_api.export(options.merge(path: link)) }.to raise_error(SystemCallError)
|
|
334
|
+
expect { backup_api.import(transport_obj: peer.handle, path: link, dry_run: true) }.to raise_error(SystemCallError)
|
|
335
|
+
peer.sent.clear
|
|
336
|
+
expect(backup_api.import(transport_obj: peer.handle, path: path, dry_run: true)[:planned]).to eq(5)
|
|
337
|
+
expect { backup_api.import(transport_obj: peer.handle, path: path, backup: result[:backup]) }.to raise_error(ArgumentError)
|
|
338
|
+
expect(peer.sent).to be_empty
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
describe 'Meshtastic::Admin::Backup missing write ACK recovery' do
|
|
344
|
+
let(:backup_api) { Meshtastic::Admin::Backup }
|
|
345
|
+
let(:peer) { BackupRadioPeer.new }
|
|
346
|
+
let(:profile) { Meshtastic::DeviceProfile.new(module_config: { mqtt: { enabled: true, password: 'synthetic-secret' } }, config: { lora: { region: :US } }) }
|
|
347
|
+
let(:options) { { transport_obj: peer.handle, backup: profile.to_proto, edit_transaction: true, timeout: 0.15 } }
|
|
348
|
+
|
|
349
|
+
before { peer.drop_ack_on = :set_module_config }
|
|
350
|
+
|
|
351
|
+
it 'fails closed when a fresh read differs, including redacted secrets' do
|
|
352
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
353
|
+
result = original.call(bytes)
|
|
354
|
+
peer.state[:MQTT_CONFIG].mqtt.password = '' if peer.sent.last.payload_variant == :set_module_config
|
|
355
|
+
result
|
|
356
|
+
end
|
|
357
|
+
result = backup_api.import(options)
|
|
358
|
+
expect(result).to include(status: :partial_failure, acknowledged: 0, readback_confirmed: 0, attempted: 1, transaction: :open)
|
|
359
|
+
expect(result[:failure]).to include(operation: :set_module_config, error: 'Timeout::Error', readback: :mismatch)
|
|
360
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config get_module_config_request])
|
|
361
|
+
expect(result.inspect).not_to include('synthetic-secret')
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it 'stops on getter timeout without later writes, commit or replay' do
|
|
365
|
+
peer.silent_on = :get_module_config_request
|
|
366
|
+
result = backup_api.import(options)
|
|
367
|
+
expect(result).to include(status: :partial_failure, acknowledged: 0, readback_confirmed: 0, transaction: :open)
|
|
368
|
+
expect(result[:failure]).to include(error: 'Timeout::Error', readback: :failed)
|
|
369
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config get_module_config_request])
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it 'never recovers a routing rejection even if the peer stored the requested state' do
|
|
373
|
+
peer.drop_ack_on = nil
|
|
374
|
+
peer.fail_on = :set_module_config
|
|
375
|
+
result = backup_api.import(options)
|
|
376
|
+
expect(result).to include(status: :partial_failure, readback_confirmed: 0, transaction: :open)
|
|
377
|
+
expect(result[:failure]).to include(error: 'Meshtastic::Admin::RoutingError', reason: :NOT_AUTHORIZED)
|
|
378
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it 'keeps a correlated getter routing rejection fatal during timeout recovery' do
|
|
382
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
383
|
+
result = original.call(bytes)
|
|
384
|
+
if peer.sent.last.payload_variant == :get_module_config_request
|
|
385
|
+
response = peer.handle[:from_radio_queue].pop(true)
|
|
386
|
+
response.packet.decoded.portnum = :ROUTING_APP
|
|
387
|
+
response.packet.decoded.payload = Meshtastic::Routing.new(error_reason: :NOT_AUTHORIZED).to_proto
|
|
388
|
+
peer.handle[:from_radio_queue] << response
|
|
389
|
+
end
|
|
390
|
+
result
|
|
391
|
+
end
|
|
392
|
+
result = backup_api.import(options)
|
|
393
|
+
expect(result).to include(status: :partial_failure, readback_confirmed: 0, transaction: :open)
|
|
394
|
+
expect(result[:failure]).to include(readback: :failed, error: 'Meshtastic::Admin::RoutingError', reason: :NOT_AUTHORIZED)
|
|
395
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config get_module_config_request])
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it 'does not recover non-timeout write errors' do
|
|
399
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
400
|
+
result = original.call(bytes)
|
|
401
|
+
raise IOError, 'synthetic transport failure' if peer.sent.last.payload_variant == :set_module_config
|
|
402
|
+
|
|
403
|
+
result
|
|
404
|
+
end
|
|
405
|
+
result = backup_api.import(options)
|
|
406
|
+
expect(result).to include(status: :partial_failure, readback_confirmed: 0, transaction: :open)
|
|
407
|
+
expect(result[:failure]).to include(error: 'IOError')
|
|
408
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config])
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it 'does not infer fixed position from other getters when no supported getter exists' do
|
|
412
|
+
peer.drop_ack_on = :set_fixed_position
|
|
413
|
+
result = backup_api.import(options.merge(backup: Meshtastic::DeviceProfile.new(fixed_position: { latitude_i: 1 }).to_proto))
|
|
414
|
+
expect(result).to include(status: :partial_failure, acknowledged: 0, readback_confirmed: 0, transaction: :open)
|
|
415
|
+
expect(result[:failure]).to include(operation: :set_fixed_position, readback: :unsupported)
|
|
416
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_fixed_position])
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
it 'keeps commit uncertainty separate from successfully readback-confirmed sections' do
|
|
420
|
+
peer.silent_on = :commit_edit_settings
|
|
421
|
+
result = backup_api.import(options)
|
|
422
|
+
expect(result).to include(status: :partial_failure, acknowledged: 1, readback_confirmed: 1, persistence_verified: false, transaction: :commit_uncertain)
|
|
423
|
+
expect(result[:failure]).to include(operation: :commit_edit_settings, error: 'Timeout::Error')
|
|
424
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[begin_edit_settings set_module_config get_module_config_request set_config commit_edit_settings])
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it 'does not recover a begin timeout by reading or writing any sections' do
|
|
428
|
+
peer.silent_on = :begin_edit_settings
|
|
429
|
+
result = backup_api.import(options)
|
|
430
|
+
expect(result).to include(status: :partial_failure, attempted: 0, readback_confirmed: 0, transaction: :begin_uncertain)
|
|
431
|
+
expect(peer.sent.map(&:payload_variant)).to eq([:begin_edit_settings])
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
it 'uses a fresh request ID and preserves stale, wrong-source, wrong-variant and late ACK packets' do
|
|
435
|
+
unrelated = []
|
|
436
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
437
|
+
result = original.call(bytes)
|
|
438
|
+
if peer.sent.last.payload_variant == :get_module_config_request
|
|
439
|
+
queue = peer.handle[:from_radio_queue]
|
|
440
|
+
correct = queue.pop(true)
|
|
441
|
+
stale = Meshtastic::FromRadio.decode(correct.to_proto)
|
|
442
|
+
stale.packet.decoded.request_id = peer.packets[-2].id
|
|
443
|
+
wrong_source = Meshtastic::FromRadio.decode(correct.to_proto)
|
|
444
|
+
wrong_source.packet.from = 0x11223344
|
|
445
|
+
wrong_variant = Meshtastic::FromRadio.decode(correct.to_proto)
|
|
446
|
+
wrong_variant.packet.decoded.payload = Meshtastic::AdminMessage.new(get_owner_response: {}).to_proto
|
|
447
|
+
late_ack = Meshtastic::FromRadio.new(packet: { from: correct.packet.from, decoded: { request_id: peer.packets[-2].id, portnum: :ROUTING_APP, payload: Meshtastic::Routing.new(error_reason: :NONE).to_proto } })
|
|
448
|
+
unrelated.push(stale, wrong_source, wrong_variant, late_ack)
|
|
449
|
+
unrelated.each { |packet| queue << packet }
|
|
450
|
+
queue << correct
|
|
451
|
+
end
|
|
452
|
+
result
|
|
453
|
+
end
|
|
454
|
+
result = backup_api.import(options)
|
|
455
|
+
expect(result).to include(status: :applied, acknowledged: 1, readback_confirmed: 1)
|
|
456
|
+
expect(peer.packets.map(&:id).uniq.length).to eq(peer.packets.length)
|
|
457
|
+
expect(Array.new(unrelated.length) { peer.handle[:from_radio_queue].pop(true) }).to eq(unrelated)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it 'does not accept incorrectly correlated matching values when no correct response arrives' do
|
|
461
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
462
|
+
result = original.call(bytes)
|
|
463
|
+
if peer.sent.last.payload_variant == :get_module_config_request
|
|
464
|
+
packet = peer.handle[:from_radio_queue].pop(true)
|
|
465
|
+
packet.packet.decoded.request_id = peer.packets[-2].id
|
|
466
|
+
peer.handle[:from_radio_queue] << packet
|
|
467
|
+
end
|
|
468
|
+
result
|
|
469
|
+
end
|
|
470
|
+
result = backup_api.import(options)
|
|
471
|
+
expect(result).to include(status: :partial_failure, readback_confirmed: 0, transaction: :open)
|
|
472
|
+
expect(result[:failure]).to include(readback: :failed, error: 'Timeout::Error')
|
|
473
|
+
expect(peer.handle[:from_radio_queue].length).to eq(1)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
it 'uses the existing owner comparison excluding generated identity metadata' do
|
|
477
|
+
peer.drop_ack_on = :set_owner
|
|
478
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
479
|
+
result = original.call(bytes)
|
|
480
|
+
if peer.sent.last.payload_variant == :set_owner
|
|
481
|
+
peer.state[:owner].id = '!aabbccdd'
|
|
482
|
+
peer.state[:owner].macaddr = 'generated'
|
|
483
|
+
end
|
|
484
|
+
result
|
|
485
|
+
end
|
|
486
|
+
result = backup_api.import(options.merge(backup: Meshtastic::DeviceProfile.new(long_name: 'Portable', is_unmessagable: false).to_proto, verify: true))
|
|
487
|
+
expect(result).to include(status: :readback_matched, acknowledged: 0, readback_confirmed: 1, readback_matched: 1, persistence_verified: false)
|
|
488
|
+
expect(result[:records].first).to include(status: :readback_confirmed, readback: :matched)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
describe 'Meshtastic::Admin::Backup DeviceProfile validation' do
|
|
493
|
+
let(:backup_api) { Meshtastic::Admin::Backup }
|
|
494
|
+
let(:peer) { BackupRadioPeer.new }
|
|
495
|
+
|
|
496
|
+
it 'does not claim an absent optional owner flag readback matches an explicitly present false value' do
|
|
497
|
+
allow(peer).to receive(:write).and_wrap_original do |original, bytes|
|
|
498
|
+
result = original.call(bytes)
|
|
499
|
+
peer.state[:owner].clear_is_unmessagable if peer.sent.last.payload_variant == :set_owner
|
|
500
|
+
result
|
|
501
|
+
end
|
|
502
|
+
profile = Meshtastic::DeviceProfile.new(is_unmessagable: false)
|
|
503
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, verify: true, timeout: 0.3)
|
|
504
|
+
expect(result).to include(status: :readback_incomplete, readback_matched: 0)
|
|
505
|
+
expect(result[:records].first[:readback]).to eq(:mismatch)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
it 'maps multiple URL channels and URL-only LoRa without manufacturing absent fields' do
|
|
509
|
+
channels = Meshtastic::ChannelSet.new(settings: [{ psk: "\x01".b }, { psk: "\x00".b }], lora_config: { region: :US })
|
|
510
|
+
profile = Meshtastic::DeviceProfile.new(channel_url: "https://meshtastic.org/d/##{Base64.urlsafe_encode64(channels.to_proto)}")
|
|
511
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, timeout: 0.3)
|
|
512
|
+
expect(result[:plan]).to eq([{ section: 'channel', slot: 0 }, { section: 'channel', slot: 1 }, { section: 'config', slot: 'LORA_CONFIG' }])
|
|
513
|
+
expect(peer.sent.take(2).map { |message| message.set_channel.role }).to eq(%i[PRIMARY SECONDARY])
|
|
514
|
+
expect(peer.sent.last.set_config.lora).to eq(channels.lora_config)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
it 'keeps ordinary profile writes in source plan order before disruptive sections' do
|
|
518
|
+
modules = Meshtastic::ModuleConfig.descriptor.to_h { |field| [field.name.to_sym, {}] }
|
|
519
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'order', ringtone: '', canned_messages: '', config: { device: {}, position: {}, power: {}, display: {}, lora: {}, security: {} }, module_config: modules)
|
|
520
|
+
plan = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true)[:plan]
|
|
521
|
+
expect(plan.map { |entry| entry[:slot] || entry[:section] }).to eq(%w[owner ringtone canned_messages DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG DISPLAY_CONFIG] + backup_api::MODULE_TYPES.map(&:to_s) + %w[LORA_CONFIG SECURITY_CONFIG])
|
|
522
|
+
expect(peer.sent).to be_empty
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
it 'auto-detects binary content and cfg paths while retaining explicit and automatic JSON input' do
|
|
526
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'x' * 123, is_unmessagable: false)
|
|
527
|
+
Dir.mktmpdir do |directory|
|
|
528
|
+
['profile.cfg', 'profile.bin'].each do |name|
|
|
529
|
+
path = File.join(directory, name)
|
|
530
|
+
File.binwrite(path, profile.to_proto)
|
|
531
|
+
expect(backup_api.import(transport_obj: peer.handle, path: path, dry_run: true)[:planned]).to eq(1)
|
|
532
|
+
end
|
|
533
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true)[:planned]).to eq(1)
|
|
534
|
+
document = { 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [] }
|
|
535
|
+
%i[auto json].each do |format|
|
|
536
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: JSON.generate(document), format: format, dry_run: true)[:planned]).to eq(0)
|
|
537
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: document, format: format, dry_run: true)[:planned]).to eq(0)
|
|
538
|
+
end
|
|
539
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :unknown, dry_run: true) }.to raise_error(ArgumentError)
|
|
540
|
+
expect(peer.sent).to be_empty
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
it 'rejects malformed, unknown, duplicate or empty binary profiles before all Admin requests' do
|
|
545
|
+
valid = Meshtastic::DeviceProfile.new(long_name: 'synthetic').to_proto
|
|
546
|
+
invalid = ['', 'arbitrary bytes', "\x0a\x05x".b, "\x58\x01".b, valid + "\x58\x01".b, valid + valid,
|
|
547
|
+
"\x08\x01".b, "\x48\x02".b, "\x48\x80\x00".b, "\x22\x04\x0a\x02\x08\x7f".b,
|
|
548
|
+
"\x22\x04\x0a\x02\xf8\x07".b, Meshtastic::DeviceProfile.new(config: { version: 24 }).to_proto,
|
|
549
|
+
Meshtastic::DeviceProfile.new(config: { lora: { frequency_offset: Float::NAN } }).to_proto,
|
|
550
|
+
"\x0a\x01\xff".b, "\x0a\xff\xff\xff\xff\xff\xff\xff\xff\xff\x02".b,
|
|
551
|
+
"\x22\x09\x0a\x07\x18\x80\x80\x80\x80\x80\x01".b]
|
|
552
|
+
invalid.each do |bytes|
|
|
553
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: bytes, format: :device_profile, edit_transaction: true) }.to raise_error(ArgumentError)
|
|
554
|
+
expect(peer.sent).to be_empty
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
it 'validates the entire channel URL including wire fields and conflicts before any writes' do
|
|
559
|
+
good = Meshtastic::ChannelSet.new(settings: [{ psk: "\x01".b }], lora_config: { region: :US })
|
|
560
|
+
encode_url = ->(bytes) { "https://meshtastic.org/e/##{Base64.urlsafe_encode64(bytes, padding: false)}" }
|
|
561
|
+
urls = ['not a URL', '', encode_url.call(''), encode_url.call(good.to_proto + "\x18\x01".b),
|
|
562
|
+
encode_url.call(Meshtastic::ChannelSet.new(settings: [{ psk: 'invalid' }]).to_proto),
|
|
563
|
+
encode_url.call(good.to_proto).sub('/e/', '/e/?add=true')]
|
|
564
|
+
urls.each do |url|
|
|
565
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'synthetic', channel_url: url)
|
|
566
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, edit_transaction: true) }.to raise_error(ArgumentError)
|
|
567
|
+
expect(peer.sent).to be_empty
|
|
568
|
+
end
|
|
569
|
+
profile = Meshtastic::DeviceProfile.new(config: { lora: { region: :UNSET } }, channel_url: encode_url.call(good.to_proto))
|
|
570
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile) }.to raise_error(ArgumentError, /conflicting/)
|
|
571
|
+
expect(peer.sent).to be_empty
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
describe 'Meshtastic::Admin::Backup validation and readback' do
|
|
576
|
+
let(:backup_api) { Meshtastic::Admin.const_get(:Backup) }
|
|
577
|
+
let(:peer) { BackupRadioPeer.new }
|
|
578
|
+
let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
|
|
579
|
+
|
|
580
|
+
it 'rejects every malformed document before begin-edit, including unknown fields and conflicting oneofs' do
|
|
581
|
+
document = backup_api.export(options)[:backup]
|
|
582
|
+
mutations = [
|
|
583
|
+
->(d) { d['version'] = 2 }, ->(d) { d['version'] = 1.0 }, ->(d) { d['unknown'] = true },
|
|
584
|
+
->(d) { d['records'] << d['records'].first.dup },
|
|
585
|
+
->(d) { d['records'][0]['value']['id'] = '!aabbccdd' },
|
|
586
|
+
->(d) { d['records'][1]['value']['device']['unknown'] = true },
|
|
587
|
+
->(d) { d['records'][1]['value']['device']['serial_enabled'] = 'false' },
|
|
588
|
+
->(d) { d['records'][1]['value']['device']['button_gpio'] = -1 },
|
|
589
|
+
->(d) { d['records'][1]['value']['network'] = {} },
|
|
590
|
+
->(d) { d['records'][1]['value'] = {} },
|
|
591
|
+
->(d) { d['records'][1]['slot'] = 'SESSIONKEY_CONFIG' },
|
|
592
|
+
->(d) { d['records'][3]['value']['index'] = 2 },
|
|
593
|
+
->(d) { d['records'][3]['value']['settings']['psk'] = 'AP8' },
|
|
594
|
+
->(d) { d['records'][4]['value']['screen_lock'] = nil }
|
|
595
|
+
]
|
|
596
|
+
peer.sent.clear
|
|
597
|
+
mutations.each do |mutate|
|
|
598
|
+
invalid = Marshal.load(Marshal.dump(document))
|
|
599
|
+
mutate.call(invalid)
|
|
600
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: invalid, edit_transaction: true) }.to raise_error(ArgumentError)
|
|
601
|
+
expect(peer.sent).to be_empty
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
it 'rejects unsupported transports and invalid options before any requests' do
|
|
606
|
+
expect { backup_api.export(transport_obj: MQTTClient.new) }.to raise_error(ArgumentError, /MQTT/)
|
|
607
|
+
expect { backup_api.import(transport_obj: MQTTClient.new, backup: {}, dry_run: true) }.to raise_error(ArgumentError, /MQTT/)
|
|
608
|
+
[{ config_types: [:SESSIONKEY_CONFIG] }, { module_config_types: [:BOGUS] }, { channel_indexes: [0, 0] },
|
|
609
|
+
{ channel_indexes: [8] }, { include_ui: 'false' }, { dry_run: true }, { session_passkey: 'secret!!' }].each do |invalid|
|
|
610
|
+
expect { backup_api.export(options.merge(invalid)) }.to raise_error(ArgumentError)
|
|
611
|
+
end
|
|
612
|
+
expect(peer.sent).to be_empty
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
it 'optionally compares fresh readback while still refusing to claim durable persistence' do
|
|
616
|
+
document = backup_api.export(options.merge(module_config_types: [], include_ui: false))[:backup]
|
|
617
|
+
peer.sent.clear
|
|
618
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, verify: true, timeout: 0.3)
|
|
619
|
+
expect(result).to include(status: :readback_matched, readback_matched: 3, acknowledged: 3, persistence_verified: false)
|
|
620
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[set_owner set_config set_channel get_owner_request get_config_request get_channel_request])
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
it 'rejects wrong section responses rather than publishing a mislabeled backup' do
|
|
624
|
+
peer.state[:device] = Meshtastic::Config.new(network: {})
|
|
625
|
+
expect { backup_api.export(options) }.to raise_error(ArgumentError, /slot/)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
it 'fails export on timeout without creating a partial backup file' do
|
|
629
|
+
peer.silent_on = :get_config_request
|
|
630
|
+
Dir.mktmpdir do |directory|
|
|
631
|
+
path = File.join(directory, 'incomplete.json')
|
|
632
|
+
expect { backup_api.export(options.merge(path: path, timeout: 0.1)) }.to raise_error(Timeout::Error)
|
|
633
|
+
expect(File.exist?(path)).to be(false)
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
it 'preserves unrelated and incorrectly correlated packets while taking fresh reads' do
|
|
638
|
+
unrelated = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: 0xaabbccdd, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: 1, payload: Meshtastic::AdminMessage.new(get_owner_response: { long_name: 'stale' }).to_proto)))
|
|
639
|
+
peer.handle[:from_radio_queue] << unrelated
|
|
640
|
+
result = backup_api.export(options)
|
|
641
|
+
expect(result[:backup]['records'].first['value']['long_name']).to eq('Backup node')
|
|
642
|
+
expect(peer.handle[:from_radio_queue].pop(true)).to eq(unrelated)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
it 'reports readback timeouts without replaying writes or hiding successful ACKs' do
|
|
646
|
+
document = backup_api.export(options.merge(module_config_types: [], include_ui: false))[:backup]
|
|
647
|
+
peer.sent.clear
|
|
648
|
+
peer.silent_on = :get_config_request
|
|
649
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, verify: true, timeout: 0.1)
|
|
650
|
+
expect(result).to include(status: :readback_incomplete, acknowledged: 3, readback_matched: 2)
|
|
651
|
+
expect(result[:records][1]).to include(readback: :failed, readback_error: 'Timeout::Error')
|
|
652
|
+
expect(peer.sent.count { |m| m.payload_variant == :set_config }).to eq(1)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it 'documents both public operations and secret limitations in help' do
|
|
656
|
+
expect { backup_api.help }.to output(/Backup.export.*Backup.import.*format:.*:auto.*:json.*:device_profile.*dry_run.*edit_transaction.*verify/m).to_stdout
|
|
657
|
+
expect(backup_api.authors).to include('0day')
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
it 'rejects duplicate JSON keys and malformed JSON without echoing file secrets' do
|
|
661
|
+
Dir.mktmpdir do |directory|
|
|
662
|
+
path = File.join(directory, 'invalid.json')
|
|
663
|
+
File.write(path, '{"version":2,"version":1,"secret":"sensitive-content"}')
|
|
664
|
+
expect { backup_api.import(transport_obj: peer.handle, path: path) }.to raise_error(ArgumentError, /invalid backup JSON|duplicate/)
|
|
665
|
+
File.write(path, '{"secret":"sensitive-content"')
|
|
666
|
+
expect { backup_api.import(transport_obj: peer.handle, path: path) }.to raise_error(ArgumentError, 'invalid backup JSON') do |error|
|
|
667
|
+
expect(error.full_message).not_to include('sensitive-content')
|
|
668
|
+
end
|
|
669
|
+
expect(peer.sent).to be_empty
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
it 'round-trips every supported core and module selector with protobuf defaults' do
|
|
674
|
+
configs = Meshtastic::Config.descriptor.to_a.take(8).map do |field|
|
|
675
|
+
message = Meshtastic::Config.new(field.name.to_sym => {})
|
|
676
|
+
peer.state[field.name.to_sym] = message
|
|
677
|
+
message
|
|
678
|
+
end
|
|
679
|
+
modules = Meshtastic::ModuleConfig.descriptor.to_a.map.with_index do |field, index|
|
|
680
|
+
message = Meshtastic::ModuleConfig.new(field.name.to_sym => {})
|
|
681
|
+
peer.state[backup_api::MODULE_TYPES[index]] = message
|
|
682
|
+
message
|
|
683
|
+
end
|
|
684
|
+
document = backup_api.export(options.merge(config_types: backup_api::CONFIG_TYPES, module_config_types: backup_api::MODULE_TYPES, include_owner: false, include_ui: false, channel_indexes: []))[:backup]
|
|
685
|
+
peer.sent.clear
|
|
686
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, timeout: 0.3)
|
|
687
|
+
expect(result[:acknowledged]).to eq(configs.length + modules.length)
|
|
688
|
+
restored = peer.sent.map { |message| message.public_send(message.payload_variant) }
|
|
689
|
+
expect(restored).to match_array(configs + modules)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
it 'exports fresh correlated section protobuf JSON without owner identity or session passkeys' do
|
|
693
|
+
result = backup_api.export(options)
|
|
694
|
+
expect(result[:status]).to eq(:exported)
|
|
695
|
+
expect(result[:count]).to eq(5)
|
|
696
|
+
document = result[:backup]
|
|
697
|
+
expect(document).to include('format' => 'meshtastic-admin-backup', 'version' => 1)
|
|
698
|
+
text = JSON.generate(document)
|
|
699
|
+
expect(text).not_to include('passkey!', 'aabbccdd', 'macaddr', 'hw_model')
|
|
700
|
+
expect(text).to include('AP8=', 'serial_enabled', 'broker-secret')
|
|
701
|
+
expect(peer.sent.map(&:payload_variant)).to eq(%i[get_owner_request get_config_request get_module_config_request get_channel_request get_ui_config_request])
|
|
702
|
+
expect(peer.sent.find { |msg| msg.payload_variant == :get_channel_request }.get_channel_request).to eq(1)
|
|
703
|
+
end
|
|
704
|
+
end
|