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.
@@ -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