node-red-contrib-aedes 0.15.1 → 1.2.0
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.
- package/.github/workflows/nodejs.yml +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +11 -3
- package/aedes.html +44 -19
- package/aedes.js +412 -210
- package/locales/de/aedes.html +110 -0
- package/locales/de/aedes.json +54 -0
- package/locales/en-US/aedes.html +103 -12
- package/locales/en-US/aedes.json +15 -2
- package/package.json +6 -4
- package/test/aedes_last_will_spec.js +25 -48
- package/test/aedes_persist_spec.js +777 -0
- package/test/aedes_qos_spec.js +76 -77
- package/test/aedes_retain_spec.js +62 -189
- package/test/aedes_spec.js +257 -67
- package/test/aedes_ws_spec.js +107 -38
- package/test/test-utils.js +17 -0
- package/docs/DEV-SETUP.md +0 -86
- package/docs/MIGRATION-PLAN-0.51-TO-1.0.md +0 -349
- package/docs/MIGRATION-PLAN-NODE-RED-4.md +0 -107
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/* eslint-env mocha */
|
|
2
|
+
const helper = require('node-red-node-test-helper');
|
|
3
|
+
const aedesNode = require('../aedes.js');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const should = require('should');
|
|
8
|
+
|
|
9
|
+
let tmpDir;
|
|
10
|
+
|
|
11
|
+
helper.init(require.resolve('node-red'));
|
|
12
|
+
|
|
13
|
+
describe('Aedes Broker Persistence - Group 1: Happy Path', function () {
|
|
14
|
+
beforeEach(function (done) {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aedes-test-'));
|
|
16
|
+
helper.init(require.resolve('node-red'), { userDir: tmpDir });
|
|
17
|
+
helper.startServer(done);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(function (done) {
|
|
21
|
+
helper.unload().then(function () {
|
|
22
|
+
helper.stopServer(function () {
|
|
23
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
done();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should not create snapshot file when persist_to_file is false', function (done) {
|
|
30
|
+
this.timeout(10000);
|
|
31
|
+
const flow = [{
|
|
32
|
+
id: 'n1',
|
|
33
|
+
type: 'aedes broker',
|
|
34
|
+
mqtt_port: '1890',
|
|
35
|
+
persist_to_file: false,
|
|
36
|
+
name: 'Aedes Persist Test',
|
|
37
|
+
wires: [[], []]
|
|
38
|
+
}];
|
|
39
|
+
helper.load(aedesNode, flow, function () {
|
|
40
|
+
const n1 = helper.getNode('n1');
|
|
41
|
+
n1._initPromise.then(function () {
|
|
42
|
+
// After bug fix 11, properties are initialized (not undefined)
|
|
43
|
+
n1._persistEnabled.should.equal(false);
|
|
44
|
+
should.not.exist(n1._snapshotInterval);
|
|
45
|
+
const files = fs.readdirSync(tmpDir).filter(function (f) {
|
|
46
|
+
return f.startsWith('aedes-persist-');
|
|
47
|
+
});
|
|
48
|
+
files.length.should.equal(0);
|
|
49
|
+
done();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should write snapshot file on close when persist_to_file is true', function (done) {
|
|
55
|
+
this.timeout(10000);
|
|
56
|
+
const flow = [{
|
|
57
|
+
id: 'n1',
|
|
58
|
+
type: 'aedes broker',
|
|
59
|
+
mqtt_port: '1890',
|
|
60
|
+
persist_to_file: true,
|
|
61
|
+
name: 'Aedes Persist Test',
|
|
62
|
+
wires: [[], []]
|
|
63
|
+
}];
|
|
64
|
+
helper.load(aedesNode, flow, function () {
|
|
65
|
+
const n1 = helper.getNode('n1');
|
|
66
|
+
n1._initPromise.then(function () {
|
|
67
|
+
const persistFile = n1._persistFile;
|
|
68
|
+
should.exist(persistFile);
|
|
69
|
+
helper.unload().then(function () {
|
|
70
|
+
fs.existsSync(persistFile).should.be.true();
|
|
71
|
+
done();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should restore retained message from snapshot on start', function (done) {
|
|
78
|
+
this.timeout(10000);
|
|
79
|
+
const snapshotData = {
|
|
80
|
+
retained: {
|
|
81
|
+
'test/restore': {
|
|
82
|
+
topic: 'test/restore',
|
|
83
|
+
payload: Buffer.from('hello').toString('base64'),
|
|
84
|
+
qos: 0,
|
|
85
|
+
retain: true,
|
|
86
|
+
cmd: 'publish'
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
subscriptions: {}
|
|
90
|
+
};
|
|
91
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
92
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(snapshotData), 'utf8');
|
|
93
|
+
|
|
94
|
+
const flow = [{
|
|
95
|
+
id: 'n1',
|
|
96
|
+
type: 'aedes broker',
|
|
97
|
+
mqtt_port: '1890',
|
|
98
|
+
persist_to_file: true,
|
|
99
|
+
name: 'Aedes Persist Test',
|
|
100
|
+
wires: [[], []]
|
|
101
|
+
}];
|
|
102
|
+
helper.load(aedesNode, flow, function () {
|
|
103
|
+
const n1 = helper.getNode('n1');
|
|
104
|
+
n1._initPromise.then(function () {
|
|
105
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
106
|
+
console.log('Created retained stream, waiting for data...');
|
|
107
|
+
const packets = [];
|
|
108
|
+
stream.on('data', function (packet) {
|
|
109
|
+
packets.push(packet);
|
|
110
|
+
});
|
|
111
|
+
stream.on('end', function () {
|
|
112
|
+
packets.length.should.equal(1);
|
|
113
|
+
packets[0].topic.should.equal('test/restore');
|
|
114
|
+
packets[0].payload.toString().should.equal('hello');
|
|
115
|
+
done();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should write empty retained for a fresh broker', function (done) {
|
|
122
|
+
this.timeout(10000);
|
|
123
|
+
const flow = [{
|
|
124
|
+
id: 'n1',
|
|
125
|
+
type: 'aedes broker',
|
|
126
|
+
mqtt_port: '1890',
|
|
127
|
+
persist_to_file: true,
|
|
128
|
+
name: 'Aedes Persist Test',
|
|
129
|
+
wires: [[], []]
|
|
130
|
+
}];
|
|
131
|
+
helper.load(aedesNode, flow, function () {
|
|
132
|
+
const n1 = helper.getNode('n1');
|
|
133
|
+
n1._initPromise.then(function () {
|
|
134
|
+
const persistFile = n1._persistFile;
|
|
135
|
+
should.exist(persistFile);
|
|
136
|
+
helper.unload().then(function () {
|
|
137
|
+
const stat = fs.statSync(persistFile);
|
|
138
|
+
stat.isFile().should.be.true();
|
|
139
|
+
const data = JSON.parse(fs.readFileSync(persistFile, 'utf8'));
|
|
140
|
+
data.should.have.property('retained');
|
|
141
|
+
Object.keys(data.retained).length.should.equal(0);
|
|
142
|
+
done();
|
|
143
|
+
}).catch(done);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should set up periodic save interval and persist retained messages', function (done) {
|
|
149
|
+
this.timeout(10000);
|
|
150
|
+
const flow = [{
|
|
151
|
+
id: 'n1',
|
|
152
|
+
type: 'aedes broker',
|
|
153
|
+
mqtt_port: '1890',
|
|
154
|
+
persist_to_file: true,
|
|
155
|
+
name: 'Aedes Persist Test',
|
|
156
|
+
wires: [[], []]
|
|
157
|
+
}];
|
|
158
|
+
helper.load(aedesNode, flow, function () {
|
|
159
|
+
const n1 = helper.getNode('n1');
|
|
160
|
+
n1._initPromise.then(function () {
|
|
161
|
+
// Verify periodic save interval is set up
|
|
162
|
+
should.exist(n1._snapshotInterval);
|
|
163
|
+
|
|
164
|
+
// Store a retained message via public API
|
|
165
|
+
const filePath = n1._persistFile;
|
|
166
|
+
n1._broker.persistence
|
|
167
|
+
.storeRetained({
|
|
168
|
+
topic: 'test/periodic',
|
|
169
|
+
payload: Buffer.from('periodic-data'),
|
|
170
|
+
qos: 0,
|
|
171
|
+
retain: true,
|
|
172
|
+
cmd: 'publish'
|
|
173
|
+
})
|
|
174
|
+
.then(function () {
|
|
175
|
+
return helper.unload();
|
|
176
|
+
})
|
|
177
|
+
.then(function () {
|
|
178
|
+
const stat = fs.statSync(filePath);
|
|
179
|
+
stat.isFile().should.be.true();
|
|
180
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
181
|
+
data.retained.should.have.property('test/periodic');
|
|
182
|
+
done();
|
|
183
|
+
})
|
|
184
|
+
.catch(done);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('Aedes Broker Persistence - Group 2: Snapshot Integrity', function () {
|
|
191
|
+
beforeEach(function (done) {
|
|
192
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aedes-test-'));
|
|
193
|
+
helper.init(require.resolve('node-red'), { userDir: tmpDir });
|
|
194
|
+
helper.startServer(done);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(function (done) {
|
|
198
|
+
helper.unload().then(function () {
|
|
199
|
+
helper.stopServer(function () {
|
|
200
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
201
|
+
done();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should survive base64 encode/decode round-trip for binary payloads', function (done) {
|
|
207
|
+
this.timeout(10000);
|
|
208
|
+
// Create a binary buffer with all byte values 0x00-0xFF
|
|
209
|
+
const binaryPayload = Buffer.alloc(256);
|
|
210
|
+
for (let i = 0; i < 256; i++) {
|
|
211
|
+
binaryPayload[i] = i;
|
|
212
|
+
}
|
|
213
|
+
const snapshotData = {
|
|
214
|
+
retained: {
|
|
215
|
+
'test/binary': {
|
|
216
|
+
topic: 'test/binary',
|
|
217
|
+
payload: binaryPayload.toString('base64'),
|
|
218
|
+
qos: 1,
|
|
219
|
+
retain: true,
|
|
220
|
+
cmd: 'publish'
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
subscriptions: {}
|
|
224
|
+
};
|
|
225
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
226
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(snapshotData), 'utf8');
|
|
227
|
+
|
|
228
|
+
const flow = [{
|
|
229
|
+
id: 'n1',
|
|
230
|
+
type: 'aedes broker',
|
|
231
|
+
mqtt_port: '1891',
|
|
232
|
+
persist_to_file: true,
|
|
233
|
+
name: 'Aedes Persist Test',
|
|
234
|
+
wires: [[], []]
|
|
235
|
+
}];
|
|
236
|
+
helper.load(aedesNode, flow, function () {
|
|
237
|
+
const n1 = helper.getNode('n1');
|
|
238
|
+
n1._initPromise.then(function () {
|
|
239
|
+
// Verify the binary payload was restored correctly
|
|
240
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
241
|
+
const packets = [];
|
|
242
|
+
stream.on('data', function (packet) {
|
|
243
|
+
packets.push(packet);
|
|
244
|
+
});
|
|
245
|
+
stream.on('end', function () {
|
|
246
|
+
packets.length.should.equal(1);
|
|
247
|
+
packets[0].topic.should.equal('test/binary');
|
|
248
|
+
const restoredPayload = Buffer.from(packets[0].payload);
|
|
249
|
+
restoredPayload.length.should.equal(256);
|
|
250
|
+
// Verify every byte survived the round-trip
|
|
251
|
+
for (let i = 0; i < 256; i++) {
|
|
252
|
+
restoredPayload[i].should.equal(i);
|
|
253
|
+
}
|
|
254
|
+
// Now save and verify the base64 re-encoding is identical
|
|
255
|
+
const persistFile = n1._persistFile;
|
|
256
|
+
helper.unload().then(function () {
|
|
257
|
+
const saved = JSON.parse(fs.readFileSync(persistFile, 'utf8'));
|
|
258
|
+
saved.retained['test/binary'].payload.should.equal(binaryPayload.toString('base64'));
|
|
259
|
+
done();
|
|
260
|
+
}).catch(done);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should save and restore multiple retained messages', function (done) {
|
|
267
|
+
this.timeout(10000);
|
|
268
|
+
const snapshotData = {
|
|
269
|
+
retained: {
|
|
270
|
+
'home/temperature': {
|
|
271
|
+
topic: 'home/temperature',
|
|
272
|
+
payload: Buffer.from('22.5').toString('base64'),
|
|
273
|
+
qos: 0,
|
|
274
|
+
retain: true,
|
|
275
|
+
cmd: 'publish'
|
|
276
|
+
},
|
|
277
|
+
'home/humidity': {
|
|
278
|
+
topic: 'home/humidity',
|
|
279
|
+
payload: Buffer.from('65').toString('base64'),
|
|
280
|
+
qos: 1,
|
|
281
|
+
retain: true,
|
|
282
|
+
cmd: 'publish'
|
|
283
|
+
},
|
|
284
|
+
'home/status': {
|
|
285
|
+
topic: 'home/status',
|
|
286
|
+
payload: Buffer.from('online').toString('base64'),
|
|
287
|
+
qos: 2,
|
|
288
|
+
retain: true,
|
|
289
|
+
cmd: 'publish'
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
subscriptions: {}
|
|
293
|
+
};
|
|
294
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
295
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(snapshotData), 'utf8');
|
|
296
|
+
|
|
297
|
+
const flow = [{
|
|
298
|
+
id: 'n1',
|
|
299
|
+
type: 'aedes broker',
|
|
300
|
+
mqtt_port: '1891',
|
|
301
|
+
persist_to_file: true,
|
|
302
|
+
name: 'Aedes Persist Test',
|
|
303
|
+
wires: [[], []]
|
|
304
|
+
}];
|
|
305
|
+
helper.load(aedesNode, flow, function () {
|
|
306
|
+
const n1 = helper.getNode('n1');
|
|
307
|
+
n1._initPromise.then(function () {
|
|
308
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
309
|
+
const packets = [];
|
|
310
|
+
stream.on('data', function (packet) {
|
|
311
|
+
packets.push(packet);
|
|
312
|
+
});
|
|
313
|
+
stream.on('end', function () {
|
|
314
|
+
packets.length.should.equal(3);
|
|
315
|
+
const byTopic = {};
|
|
316
|
+
packets.forEach(function (p) { byTopic[p.topic] = p; });
|
|
317
|
+
byTopic['home/temperature'].payload.toString().should.equal('22.5');
|
|
318
|
+
byTopic['home/humidity'].payload.toString().should.equal('65');
|
|
319
|
+
byTopic['home/humidity'].qos.should.equal(1);
|
|
320
|
+
byTopic['home/status'].payload.toString().should.equal('online');
|
|
321
|
+
byTopic['home/status'].qos.should.equal(2);
|
|
322
|
+
done();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should skip empty-payload retained messages (deletion signal)', function (done) {
|
|
329
|
+
this.timeout(10000);
|
|
330
|
+
const flow = [{
|
|
331
|
+
id: 'n1',
|
|
332
|
+
type: 'aedes broker',
|
|
333
|
+
mqtt_port: '1891',
|
|
334
|
+
persist_to_file: true,
|
|
335
|
+
name: 'Aedes Persist Test',
|
|
336
|
+
wires: [[], []]
|
|
337
|
+
}];
|
|
338
|
+
helper.load(aedesNode, flow, function () {
|
|
339
|
+
const n1 = helper.getNode('n1');
|
|
340
|
+
n1._initPromise.then(function () {
|
|
341
|
+
// Store a retained message, then delete it with empty payload
|
|
342
|
+
n1._broker.persistence.storeRetained({
|
|
343
|
+
topic: 'test/delete-me',
|
|
344
|
+
payload: Buffer.from('will-be-deleted'),
|
|
345
|
+
qos: 0,
|
|
346
|
+
retain: true,
|
|
347
|
+
cmd: 'publish'
|
|
348
|
+
}).then(function () {
|
|
349
|
+
// MQTT delete: retain with empty payload
|
|
350
|
+
return n1._broker.persistence.storeRetained({
|
|
351
|
+
topic: 'test/delete-me',
|
|
352
|
+
payload: Buffer.alloc(0),
|
|
353
|
+
qos: 0,
|
|
354
|
+
retain: true,
|
|
355
|
+
cmd: 'publish'
|
|
356
|
+
});
|
|
357
|
+
}).then(function () {
|
|
358
|
+
// Also store one that should survive
|
|
359
|
+
return n1._broker.persistence.storeRetained({
|
|
360
|
+
topic: 'test/keep-me',
|
|
361
|
+
payload: Buffer.from('keeper'),
|
|
362
|
+
qos: 0,
|
|
363
|
+
retain: true,
|
|
364
|
+
cmd: 'publish'
|
|
365
|
+
});
|
|
366
|
+
}).then(function () {
|
|
367
|
+
const persistFile = n1._persistFile;
|
|
368
|
+
return helper.unload().then(function () {
|
|
369
|
+
const stat = fs.statSync(persistFile);
|
|
370
|
+
stat.isFile().should.be.true();
|
|
371
|
+
const data = JSON.parse(fs.readFileSync(persistFile, 'utf8'));
|
|
372
|
+
// The deleted topic should not appear in the snapshot
|
|
373
|
+
data.retained.should.not.have.property('test/delete-me');
|
|
374
|
+
// The kept topic should be present
|
|
375
|
+
data.retained.should.have.property('test/keep-me');
|
|
376
|
+
done();
|
|
377
|
+
});
|
|
378
|
+
}).catch(done);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should warn and start with empty state on corrupt JSON file', function (done) {
|
|
384
|
+
this.timeout(10000);
|
|
385
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
386
|
+
fs.writeFileSync(snapshotPath, '{ this is not valid JSON!!!', 'utf8');
|
|
387
|
+
|
|
388
|
+
const flow = [{
|
|
389
|
+
id: 'n1',
|
|
390
|
+
type: 'aedes broker',
|
|
391
|
+
mqtt_port: '1891',
|
|
392
|
+
persist_to_file: true,
|
|
393
|
+
name: 'Aedes Persist Test',
|
|
394
|
+
wires: [[], []]
|
|
395
|
+
}];
|
|
396
|
+
helper.load(aedesNode, flow, function () {
|
|
397
|
+
const n1 = helper.getNode('n1');
|
|
398
|
+
n1._initPromise.then(function () {
|
|
399
|
+
// Broker should still be running with persistence enabled
|
|
400
|
+
n1._persistEnabled.should.be.true();
|
|
401
|
+
// Verify no retained messages were loaded
|
|
402
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
403
|
+
const packets = [];
|
|
404
|
+
stream.on('data', function (packet) {
|
|
405
|
+
packets.push(packet);
|
|
406
|
+
});
|
|
407
|
+
stream.on('end', function () {
|
|
408
|
+
packets.length.should.equal(0);
|
|
409
|
+
done();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should warn and start with empty state on wrong schema (array instead of object)', function (done) {
|
|
416
|
+
this.timeout(10000);
|
|
417
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
418
|
+
// Valid JSON but wrong schema — array instead of object
|
|
419
|
+
fs.writeFileSync(snapshotPath, JSON.stringify([1, 2, 3]), 'utf8');
|
|
420
|
+
|
|
421
|
+
const flow = [{
|
|
422
|
+
id: 'n1',
|
|
423
|
+
type: 'aedes broker',
|
|
424
|
+
mqtt_port: '1891',
|
|
425
|
+
persist_to_file: true,
|
|
426
|
+
name: 'Aedes Persist Test',
|
|
427
|
+
wires: [[], []]
|
|
428
|
+
}];
|
|
429
|
+
helper.load(aedesNode, flow, function () {
|
|
430
|
+
const n1 = helper.getNode('n1');
|
|
431
|
+
n1._initPromise.then(function () {
|
|
432
|
+
// Broker should still be running with persistence enabled
|
|
433
|
+
n1._persistEnabled.should.be.true();
|
|
434
|
+
// Verify no retained messages were loaded
|
|
435
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
436
|
+
const packets = [];
|
|
437
|
+
stream.on('data', function (packet) {
|
|
438
|
+
packets.push(packet);
|
|
439
|
+
});
|
|
440
|
+
stream.on('end', function () {
|
|
441
|
+
packets.length.should.equal(0);
|
|
442
|
+
done();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
describe('Aedes Broker Persistence - Group 3: Filesystem Errors', function () {
|
|
449
|
+
beforeEach(function (done) {
|
|
450
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aedes-test-'));
|
|
451
|
+
helper.init(require.resolve('node-red'), { userDir: tmpDir });
|
|
452
|
+
helper.startServer(done);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
afterEach(function (done) {
|
|
456
|
+
helper.unload().then(function () {
|
|
457
|
+
helper.stopServer(function () {
|
|
458
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
459
|
+
done();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should warn and fall back to pure in-memory when userDir is not writable', function (done) {
|
|
465
|
+
this.timeout(10000);
|
|
466
|
+
// Make tmpDir read-only
|
|
467
|
+
fs.chmodSync(tmpDir, 0o444);
|
|
468
|
+
|
|
469
|
+
const flow = [{
|
|
470
|
+
id: 'n1',
|
|
471
|
+
type: 'aedes broker',
|
|
472
|
+
mqtt_port: '1892',
|
|
473
|
+
persist_to_file: true,
|
|
474
|
+
name: 'Aedes Persist Test',
|
|
475
|
+
wires: [[], []]
|
|
476
|
+
}];
|
|
477
|
+
helper.load(aedesNode, flow, function () {
|
|
478
|
+
const n1 = helper.getNode('n1');
|
|
479
|
+
n1._initPromise.then(function () {
|
|
480
|
+
// Verify persistence was disabled due to write check failure
|
|
481
|
+
n1._persistEnabled.should.equal(false);
|
|
482
|
+
should.not.exist(n1._snapshotInterval);
|
|
483
|
+
// Broker should still be running
|
|
484
|
+
should.exist(n1._broker);
|
|
485
|
+
// Restore write permission for cleanup
|
|
486
|
+
fs.chmodSync(tmpDir, 0o755);
|
|
487
|
+
done();
|
|
488
|
+
}).catch(function (err) {
|
|
489
|
+
fs.chmodSync(tmpDir, 0o755);
|
|
490
|
+
done(err);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should warn and start with empty state when snapshot file is not readable', function (done) {
|
|
496
|
+
this.timeout(10000);
|
|
497
|
+
// Create a snapshot file and make it unreadable
|
|
498
|
+
const snapshotData = {
|
|
499
|
+
retained: {
|
|
500
|
+
'test/should-not-load': {
|
|
501
|
+
topic: 'test/should-not-load',
|
|
502
|
+
payload: Buffer.from('should-not-load').toString('base64'),
|
|
503
|
+
qos: 0,
|
|
504
|
+
retain: true,
|
|
505
|
+
cmd: 'publish'
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
subscriptions: {}
|
|
509
|
+
};
|
|
510
|
+
const snapshotPath = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
511
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(snapshotData), 'utf8');
|
|
512
|
+
fs.chmodSync(snapshotPath, 0o000); // Make unreadable
|
|
513
|
+
|
|
514
|
+
const flow = [{
|
|
515
|
+
id: 'n1',
|
|
516
|
+
type: 'aedes broker',
|
|
517
|
+
mqtt_port: '1893',
|
|
518
|
+
persist_to_file: true,
|
|
519
|
+
name: 'Aedes Persist Test',
|
|
520
|
+
wires: [[], []]
|
|
521
|
+
}];
|
|
522
|
+
helper.load(aedesNode, flow, function () {
|
|
523
|
+
const n1 = helper.getNode('n1');
|
|
524
|
+
n1._initPromise.then(function () {
|
|
525
|
+
// Persistence should be enabled (writable dir check passed)
|
|
526
|
+
n1._persistEnabled.should.equal(true);
|
|
527
|
+
// But the snapshot should not have been loaded due to read permission issue
|
|
528
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
529
|
+
const packets = [];
|
|
530
|
+
stream.on('data', function (packet) {
|
|
531
|
+
packets.push(packet);
|
|
532
|
+
});
|
|
533
|
+
stream.on('end', function () {
|
|
534
|
+
// No retained messages should be loaded
|
|
535
|
+
packets.length.should.equal(0);
|
|
536
|
+
// Restore read permission for cleanup
|
|
537
|
+
fs.chmodSync(snapshotPath, 0o644);
|
|
538
|
+
done();
|
|
539
|
+
});
|
|
540
|
+
}).catch(function (err) {
|
|
541
|
+
fs.chmodSync(snapshotPath, 0o644);
|
|
542
|
+
done(err);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('should warn and continue when snapshot write fails (e.g., permission denied on close)', function (done) {
|
|
548
|
+
this.timeout(10000);
|
|
549
|
+
const flow = [{
|
|
550
|
+
id: 'n1',
|
|
551
|
+
type: 'aedes broker',
|
|
552
|
+
mqtt_port: '1894',
|
|
553
|
+
persist_to_file: true,
|
|
554
|
+
name: 'Aedes Persist Test',
|
|
555
|
+
wires: [[], []]
|
|
556
|
+
}];
|
|
557
|
+
helper.load(aedesNode, flow, function () {
|
|
558
|
+
const n1 = helper.getNode('n1');
|
|
559
|
+
n1._initPromise.then(function () {
|
|
560
|
+
n1._persistEnabled.should.equal(true);
|
|
561
|
+
should.exist(n1._persistFile);
|
|
562
|
+
|
|
563
|
+
// Make userDir read-only before unload to simulate write failure on close
|
|
564
|
+
fs.chmodSync(tmpDir, 0o555);
|
|
565
|
+
|
|
566
|
+
helper.unload().then(function () {
|
|
567
|
+
// Node should unload cleanly despite write failure
|
|
568
|
+
// Restore permissions for cleanup
|
|
569
|
+
fs.chmodSync(tmpDir, 0o755);
|
|
570
|
+
done();
|
|
571
|
+
}).catch(function (err) {
|
|
572
|
+
fs.chmodSync(tmpDir, 0o755);
|
|
573
|
+
done(err);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('should gracefully handle missing snapshot file and start with empty state', function (done) {
|
|
580
|
+
this.timeout(10000);
|
|
581
|
+
// Explicitly do NOT create a snapshot file
|
|
582
|
+
const flow = [{
|
|
583
|
+
id: 'n1',
|
|
584
|
+
type: 'aedes broker',
|
|
585
|
+
mqtt_port: '1895',
|
|
586
|
+
persist_to_file: true,
|
|
587
|
+
name: 'Aedes Persist Test',
|
|
588
|
+
wires: [[], []]
|
|
589
|
+
}];
|
|
590
|
+
helper.load(aedesNode, flow, function () {
|
|
591
|
+
const n1 = helper.getNode('n1');
|
|
592
|
+
n1._initPromise.then(function () {
|
|
593
|
+
n1._persistEnabled.should.equal(true);
|
|
594
|
+
should.exist(n1._persistFile);
|
|
595
|
+
// Verify no retained messages are present
|
|
596
|
+
const stream = n1._broker.persistence.createRetainedStream('#');
|
|
597
|
+
const packets = [];
|
|
598
|
+
stream.on('data', function (packet) {
|
|
599
|
+
packets.push(packet);
|
|
600
|
+
});
|
|
601
|
+
stream.on('end', function () {
|
|
602
|
+
packets.length.should.equal(0);
|
|
603
|
+
done();
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
describe('Aedes Broker Persistence - Group 4: Configuration', function () {
|
|
611
|
+
beforeEach(function (done) {
|
|
612
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aedes-test-'));
|
|
613
|
+
helper.init(require.resolve('node-red'), { userDir: tmpDir });
|
|
614
|
+
helper.startServer(done);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
afterEach(function (done) {
|
|
618
|
+
helper.unload().then(function () {
|
|
619
|
+
helper.stopServer(function () {
|
|
620
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
621
|
+
done();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should not create snapshot file when persist_to_file is false', function (done) {
|
|
627
|
+
this.timeout(10000);
|
|
628
|
+
const flow = [{
|
|
629
|
+
id: 'n1',
|
|
630
|
+
type: 'aedes broker',
|
|
631
|
+
mqtt_port: '1896',
|
|
632
|
+
persist_to_file: false,
|
|
633
|
+
name: 'Aedes No Persist Test',
|
|
634
|
+
wires: [[], []]
|
|
635
|
+
}];
|
|
636
|
+
helper.load(aedesNode, flow, function () {
|
|
637
|
+
const n1 = helper.getNode('n1');
|
|
638
|
+
n1._initPromise.then(function () {
|
|
639
|
+
// Verify persistence is disabled
|
|
640
|
+
n1._persistEnabled.should.equal(false);
|
|
641
|
+
should.not.exist(n1._snapshotInterval);
|
|
642
|
+
should.not.exist(n1._persistFile);
|
|
643
|
+
// Store a retained message
|
|
644
|
+
n1._broker.persistence.storeRetained({
|
|
645
|
+
topic: 'test/no-persist',
|
|
646
|
+
payload: Buffer.from('should-not-persist'),
|
|
647
|
+
qos: 0,
|
|
648
|
+
retain: true,
|
|
649
|
+
cmd: 'publish'
|
|
650
|
+
}).then(function () {
|
|
651
|
+
return helper.unload();
|
|
652
|
+
}).then(function () {
|
|
653
|
+
// Verify no snapshot file was created
|
|
654
|
+
const files = fs.readdirSync(tmpDir).filter(function (f) {
|
|
655
|
+
return f.startsWith('aedes-persist-');
|
|
656
|
+
});
|
|
657
|
+
files.length.should.equal(0);
|
|
658
|
+
done();
|
|
659
|
+
}).catch(done);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('should assign separate snapshot files to different broker instances', function (done) {
|
|
665
|
+
this.timeout(10000);
|
|
666
|
+
const flow = [
|
|
667
|
+
{
|
|
668
|
+
id: 'n1',
|
|
669
|
+
type: 'aedes broker',
|
|
670
|
+
mqtt_port: '1897',
|
|
671
|
+
persist_to_file: true,
|
|
672
|
+
name: 'Broker 1',
|
|
673
|
+
wires: [[], []]
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
id: 'n2',
|
|
677
|
+
type: 'aedes broker',
|
|
678
|
+
mqtt_port: '1898',
|
|
679
|
+
persist_to_file: true,
|
|
680
|
+
name: 'Broker 2',
|
|
681
|
+
wires: [[], []]
|
|
682
|
+
}
|
|
683
|
+
];
|
|
684
|
+
helper.load(aedesNode, flow, function () {
|
|
685
|
+
const n1 = helper.getNode('n1');
|
|
686
|
+
const n2 = helper.getNode('n2');
|
|
687
|
+
|
|
688
|
+
Promise.all([n1._initPromise, n2._initPromise]).then(function () {
|
|
689
|
+
// Both should have persistence enabled
|
|
690
|
+
n1._persistEnabled.should.equal(true);
|
|
691
|
+
n2._persistEnabled.should.equal(true);
|
|
692
|
+
|
|
693
|
+
// Files should be different (based on node.id)
|
|
694
|
+
const file1 = n1._persistFile;
|
|
695
|
+
const file2 = n2._persistFile;
|
|
696
|
+
should.exist(file1);
|
|
697
|
+
should.exist(file2);
|
|
698
|
+
file1.should.not.equal(file2);
|
|
699
|
+
|
|
700
|
+
// Store different retained messages in each broker
|
|
701
|
+
return Promise.all([
|
|
702
|
+
n1._broker.persistence.storeRetained({
|
|
703
|
+
topic: 'broker1/test',
|
|
704
|
+
payload: Buffer.from('broker1-data'),
|
|
705
|
+
qos: 0,
|
|
706
|
+
retain: true,
|
|
707
|
+
cmd: 'publish'
|
|
708
|
+
}),
|
|
709
|
+
n2._broker.persistence.storeRetained({
|
|
710
|
+
topic: 'broker2/test',
|
|
711
|
+
payload: Buffer.from('broker2-data'),
|
|
712
|
+
qos: 0,
|
|
713
|
+
retain: true,
|
|
714
|
+
cmd: 'publish'
|
|
715
|
+
})
|
|
716
|
+
]);
|
|
717
|
+
}).then(function () {
|
|
718
|
+
return helper.unload();
|
|
719
|
+
}).then(function () {
|
|
720
|
+
// Verify both files exist and contain their own data
|
|
721
|
+
const file1 = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
722
|
+
const file2 = path.join(tmpDir, 'aedes-persist-n2.json');
|
|
723
|
+
fs.existsSync(file1).should.be.true();
|
|
724
|
+
fs.existsSync(file2).should.be.true();
|
|
725
|
+
|
|
726
|
+
const data1 = JSON.parse(fs.readFileSync(file1, 'utf8'));
|
|
727
|
+
const data2 = JSON.parse(fs.readFileSync(file2, 'utf8'));
|
|
728
|
+
|
|
729
|
+
// Each should have only its own retained message
|
|
730
|
+
data1.retained.should.have.property('broker1/test');
|
|
731
|
+
data1.retained.should.not.have.property('broker2/test');
|
|
732
|
+
data2.retained.should.have.property('broker2/test');
|
|
733
|
+
data2.retained.should.not.have.property('broker1/test');
|
|
734
|
+
|
|
735
|
+
done();
|
|
736
|
+
}).catch(done);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('should clear interval and save final snapshot on close', function (done) {
|
|
741
|
+
this.timeout(10000);
|
|
742
|
+
const flow = [{
|
|
743
|
+
id: 'n1',
|
|
744
|
+
type: 'aedes broker',
|
|
745
|
+
mqtt_port: '1899',
|
|
746
|
+
persist_to_file: true,
|
|
747
|
+
name: 'Aedes Persist Test',
|
|
748
|
+
wires: [[], []]
|
|
749
|
+
}];
|
|
750
|
+
helper.load(aedesNode, flow, function () {
|
|
751
|
+
const n1 = helper.getNode('n1');
|
|
752
|
+
n1._initPromise.then(function () {
|
|
753
|
+
// Verify interval is set
|
|
754
|
+
should.exist(n1._snapshotInterval);
|
|
755
|
+
|
|
756
|
+
// Store a retained message
|
|
757
|
+
return n1._broker.persistence.storeRetained({
|
|
758
|
+
topic: 'test/close',
|
|
759
|
+
payload: Buffer.from('close-test'),
|
|
760
|
+
qos: 0,
|
|
761
|
+
retain: true,
|
|
762
|
+
cmd: 'publish'
|
|
763
|
+
}).then(function () {
|
|
764
|
+
return helper.unload();
|
|
765
|
+
});
|
|
766
|
+
}).then(function () {
|
|
767
|
+
// Verify the snapshot file was created with the retained message
|
|
768
|
+
const persistFile = path.join(tmpDir, 'aedes-persist-n1.json');
|
|
769
|
+
fs.existsSync(persistFile).should.be.true();
|
|
770
|
+
const data = JSON.parse(fs.readFileSync(persistFile, 'utf8'));
|
|
771
|
+
data.retained.should.have.property('test/close');
|
|
772
|
+
data.retained['test/close'].payload.should.equal(Buffer.from('close-test').toString('base64'));
|
|
773
|
+
done();
|
|
774
|
+
}).catch(done);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|