• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

suculent / thinx-device-api / #252646768

01 Nov 2025 03:31PM UTC coverage: 46.298% (-25.7%) from 71.971%
#252646768

push

suculent
testing upgraded mqtt package

1123 of 3470 branches covered (32.36%)

Branch coverage included in aggregate %.

5324 of 10455 relevant lines covered (50.92%)

4.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

6.12
/lib/thinx/transfer.js
1
/** This THiNX Device Management API module is responsible for device transfer management. */
2

3
var Globals = require("./globals.js");
1✔
4
var app_config = Globals.app_config();
1✔
5
var prefix = Globals.prefix();
1✔
6

7
const formData = require('form-data');
1✔
8
const Mailgun = require('mailgun.js');
1✔
9
const mailgun = new Mailgun(formData);
1✔
10
const mg = mailgun.client({
1✔
11
        username: 'api',
12
        key: process.env.MAILGUN_API_KEY
13
});
14

15
var fs = require("fs-extra");
1✔
16

17
const Database = require("./database.js");
1✔
18
let db_uri = new Database().uri();
1✔
19
var userlib = require("nano")(db_uri).use(prefix + "managed_users");
1✔
20
var sha256 = require("sha256");
1✔
21

22
var AuditLog = require("./audit"); var alog = new AuditLog();
1✔
23
var Device = require("./device");
1✔
24
var Devices = require("./devices");
1✔
25

26
const { v4: uuidV4 } = require('uuid');
1✔
27
const Util = require("./util.js");
1✔
28
const Filez = require("./files.js");
1✔
29

30
module.exports = class Transfer {
1✔
31

32
        constructor(messenger, redis) {
33
                this.redis = redis;
2✔
34
                this.messenger = messenger;
2✔
35
                this.devices = new Devices(messenger, redis);
2✔
36
                this.device = new Device(redis);
2✔
37
        }
38

39
        // migration
40

41
        transfer_valid(encoded_json_keys, dtid, callback) {
42

43
                var json_keys = JSON.parse(encoded_json_keys);
×
44

45
                if (json_keys === null) {
×
46
                        console.log("[transfer] No udids remaining, expiring record...");
×
47
                        this.redis.del(dtid);
×
48
                        callback(true, "transfer_completed");
×
49
                        return false;
×
50
                }
51

52
                return true; // no callback called, continue with transfer...
×
53
        }
54

55
        migrate_device(original_owner, xudid, recipient, body, json_keys, callback) {
56

57
                if (recipient !== original_owner) {
×
58

59
                        let changes = {
×
60
                                udid: xudid,
61
                                owner: recipient,
62
                                previous_owner: original_owner
63
                        };
64

65
                        this.device.edit(changes, (success, xchanges) => {
×
66
                                if (!success) console.log("🔨 [debug] [transfer] DEC", { success }, { xchanges });
×
67
                        });
68

69
                } else {
70
                        console.log("☣️ [error] owner and previous owner are the same in migration!");
×
71
                }
72

73
                delete json_keys.udids[xudid];
×
74

75
                // Move all data:
76
                const original_path = Filez.deployPathForDevice(original_owner, xudid);
×
77
                const destination_path = Filez.deployPathForDevice(recipient, xudid);
×
78
                if (fs.existsSync(original_path)) {
×
79
                        this.rename(original_path, destination_path);
×
80
                } else {
81
                        console.log("⚠️ [warning] [transfer] original device path does not exist.");
×
82
                }
83

84
                console.log("ℹ️ [info] [transfer] Device builds artefacts transfer ended.");
×
85

86
                // Move all repositories/move sources
87

88
                if (body.mig_sources === true) {
×
89
                        var old_sources_path = original_path.replace(app_config.deploy_root, app_config.data_root + app_config.build_root);
×
90
                        var new_sources_path = destination_path.replace(app_config.deploy_root, app_config.data_root + app_config.build_root);
×
91
                        console.log("Should rename " + old_sources_path + " to " + new_sources_path);
×
92
                        if (fs.existsSync(old_sources_path)) {
×
93
                                this.rename(old_sources_path, new_sources_path);
×
94
                        } else {
95
                                console.log("Warning, old sources path does not exist.");
×
96
                        }
97

98
                        const usid = device.source;
×
99
                        this.move_source(usid, original_owner, recipient, (success) => {
×
100
                                if (success) {
×
101
                                        this.attach_source(recipient, usid, xudid);
×
102
                                }
103
                        });
104
                }
105

106
                // Move all repositories:
107
                if (body.api_keys === true) {
×
108
                        // #THX-396: Migrate API Keys from original owner to recipient in Redis!"
109
                        // get device, fetch its API Key hash, find this in Redis and migrate to another user
110
                        this.migrate_api_keys(original_owner, recipient, xudid, callback);
×
111
                } else {
112
                        callback();
×
113
                }
114
        }
115

116
        migrate_api_keys(original_owner, recipient, xudid, callback) {
117

118
                var source_id = "ak:" + original_owner;
×
119
                var recipient_id = "ak:" + recipient;
×
120

121
                this.devices.get(xudid, (success, dev) => {
×
122
                        if (!success) {
×
123
                                console.log("[critical] device get for migration failed!");
×
124
                                return;
×
125
                        }
126
                        const last_key_hash = dev.lastkey;
×
127

128
                        // Get source keys
129
                        this.redis.get(source_id, (error, json_keys) => {
×
130
                                var json_array = JSON.parse(json_keys);
×
131
                                var delete_this = null;
×
132
                                var migrate_this = null;
×
133
                                for (var ai in json_array) {
×
134
                                        var item = json_array[ai];
×
135
                                        if (sha256(item) == last_key_hash) {
×
136
                                                delete_this = ai;
×
137
                                                migrate_this = item;
×
138
                                        }
139
                                }
140

141
                                // Get recipient keys
142
                                this.redis.get(recipient_id, (error, recipient_keys) => {
×
143
                                        var recipient_array = JSON.parse(recipient_keys);
×
144
                                        if (delete_this) {
×
145
                                                recipient_array.push(migrate_this);
×
146
                                                delete json_array[delete_this];
×
147
                                        }
148
                                        // Save array with respective API Key removed
149
                                        this.redis.set(source_id, JSON.stringify(json_array));
×
150
                                        // Save new array with respective API Key added
151
                                        this.redis.set(recipient_id, JSON.stringify(recipient_array));
×
152
                                        callback(true, "api_keys_migrated"); // ??
×
153
                                });
154

155
                        }); // this.redis.get
156

157
                }); // devices.get
158

159
        } // end migrate_api_keys
160

161
        rename(from, to) {
162
                fs.copy(from, to, (rename_err) => {
×
163
                        if (rename_err) {
×
164
                                console.log("☣️ [error] [transfer] caught COPY error:", rename_err);
×
165
                        } else {
166
                                fs.remove(from);
×
167
                        }
168
                });
169
        }
170

171
        move_source(usid, original_owner, target_owner, callback) {
172

173
                userlib.get(original_owner, (err1, abody) => {
×
174

175
                        if (err1) return callback(false, err1);
×
176

177
                        userlib.get(target_owner, (err2, bbody) => {
×
178

179
                                if (err2) return callback(false, err2);
×
180

181
                                var osources = abody.sources;
×
182
                                var tsources = bbody.sources;
×
183
                                tsources[usid] = osources[usid];
×
184

185
                                delete osources[usid];
×
186

187
                                userlib.atomic("users", "edit", abody._id, {
×
188
                                        sources: tsources
189
                                }, (error/* , response */) => {
190
                                        if (error) {
×
191
                                                console.log("☣️ [error] Source transfer failed: " + error);
×
192
                                        } else {
193
                                                alog.log(abody._id, "Source transfer succeeded.");
×
194
                                        }
195
                                });
196

197
                        });
198
                });
199
        }
200

201
        attach_source(target_owner, usid, udid) {
202
                this.devices.attach(target_owner, {
×
203
                        source_id: usid,
204
                        udid: udid
205
                }, (success, response) => {
206
                        if (!success) {
×
207
                                console.log("☣️ [error] Migration error:" + response);
×
208
                        }
209
                        if (response) {
×
210
                                console.log("ℹ️ [info] Migration response:" + response);
×
211
                        }
212
                });
213
        }
214

215
        exit_on_transfer(udid, result_callback) {
216

217
                this.redis.get("dtr:" + udid, (error, reply) => {
×
218
                        if ((reply === null) || (reply == [])) {
×
219
                                console.log("ℹ️ [info] exit_on_transfer reply", { reply });
×
220
                                console.log("ℹ️ [info] Device already being transferred:", udid);
×
221
                                result_callback(false);
×
222
                        } else {
223
                                result_callback(true);
×
224
                        }
225
                });
226
        }
227

228
        store_pending_transfer(udid, transfer_id) {
229
                this.redis.set("dtr:" + udid, transfer_id);
×
230
                this.redis.expire("dtr:" + udid, 86400); // expire pending transfer in one day...
×
231
        }
232

233
        // public
234

235
        sendMail(contents, type, callback) {
236
                mg.messages.create(app_config.mailgun.domain, contents)
×
237
                        .then((/* msg */) => {
238
                                callback(true, {
×
239
                                        success: true,
240
                                        response: type + "_sent"
241
                                });
242
                        })
243
                        .catch((mail_err) => {
244
                                console.log(`☣️ [error] mailgun error ${mail_err}`);
×
245
                                callback(false, type + "_failed", mail_err);
×
246
                        });
247
        }
248

249
        request(owner, body, callback) {
250

251
                // body should look like { "to":"some@email.com", "udids" : [ "some-udid", "another-udid" ] }
252

253
                // THX-396
254

255
                // when true, sources will be COPIED to new owner as well
256
                if (!Util.isDefined(body.mig_sources)) body.mig_sources = false;
×
257

258
                // when true, API Keys will be TRANSFERRED:
259
                // - MQTT combination udid/apikey does not change
260
                // - API Keys are deleted from sender and added to recipient
261
                if (!Util.isDefined(body.mig_apikeys)) body.mig_apikeys = false;
×
262

263
                // Generic Check
264
                if (!Util.isDefined(body.to)) return callback(false, "missing_recipient");
×
265
                if (!Util.isDefined(body.udids)) return callback(false, "missing_subject");
×
266

267
                var recipient_id = sha256(prefix + body.to);
×
268

269
                var result = true;
×
270

271
                // TODO: FIXME: Turn this code into a promise, otherwise it won't work... it will evaluate result before callbacks may be called and will not initiate transfer at all.
272

273
                // check whether this device is not transferred already
274
                for (var udid in body.udids) {
×
275
                        this.exit_on_transfer(udid, (status) => {
×
276
                                if (status > 0)
×
277
                                console.log(`[error] transfer status for ${udid} is ${status}`);
×
278
                                result = status;
×
279
                        });
280
                }
281

282
                // When the loop above completes (we should give it a time...)
283
                // `result` should stay true otherwise there's already
284
                // transfer in progress.
285

286
                console.log(`[debug] transfer status evaluation: ${result}`);
×
287

288
                if (result === false) {
×
289
                        return callback(false, "transfer_already_in_progress");
×
290
                }
291

292
                userlib.get(owner, (couch_err, ownerdoc) => {
×
293

294
                        if (couch_err) {
×
295
                                console.log("Owner", owner, "unknown in transfer request!");
×
296
                                return callback(false, "owner_unknown");
×
297
                        }
298

299
                        userlib.get(recipient_id, (zerr/* , recipient */) => {
×
300

301
                                if (zerr) {
×
302
                                        console.log("☣️ [error] Transfer target body.to id " + recipient_id + "not found");
×
303
                                        return callback(false, "recipient_unknown");
×
304
                                }
305

306
                                // 2. add recipient to body as "from"
307
                                body.from = ownerdoc.email;
×
308

309
                                // 2. add recipient to body as "from"
310

311
                                // 3. store as "dt:uuid()" to redis
312
                                var transfer_uuid = uuidV4(); // used for email
×
313
                                var transfer_id = "dt:" + transfer_uuid;
×
314

315
                                this.redis.set(transfer_id, JSON.stringify(body));
×
316

317
                                // 4. respond with success/failure to the request
318
                                callback(true, transfer_uuid);
×
319

320
                                let udids = body.udids;
×
321

322
                                if (typeof (udids) === "undefined") {
×
323
                                        console.log("Transfer expects udids in body", body);
×
324
                                        return;
×
325
                                }
326

327
                                for (var did in udids) {
×
328
                                        this.store_pending_transfer(did, transfer_id);
×
329
                                }
330

331
                                var htmlDeviceList = "<p><ul>";
×
332
                                for (var dindex in body.udids) {
×
333
                                        htmlDeviceList += "<li>" + udids[dindex] + "</li>";
×
334
                                }
335
                                htmlDeviceList += "</ul></p>";
×
336

337
                                var plural = "";
×
338
                                if (body.udids.length > 1) plural = "s";
×
339

340
                                var port = "";
×
341
                                if (typeof (app_config.debug.allow_http_login) !== "undefined" && app_config.debug.allow_http_login === true) {
×
342
                                        port = app_config.port;
×
343
                                }
344

345
                                var recipientTransferEmail = {
×
346
                                        from: 'THiNX API <api@' + app_config.mailgun.domain + '>',
347
                                        to: body.to,
348
                                        subject: "Device transfer requested",
349
                                        text: "<!DOCTYPE html><p>Hello " + body.to + ".</p>" +
350
                                                "<p> User with e-mail " + body.from +
351
                                                " is transferring following device" + plural + " to you:</p>" +
352
                                                htmlDeviceList +
353
                                                "<p>You may " +
354
                                                "<a href='" + app_config.api_url + port + "/api/transfer/accept?transfer_id=" +
355
                                                transfer_uuid + "'>Accept</a> or" +
356
                                                "<a href='" + app_config.api_url + port + "/api/transfer/decline?transfer_id=" +
357
                                                transfer_uuid + "'>Decline</a> this offer.</p>" +
358
                                                "</html>"
359
                                };
360

361
                                console.log(`ℹ️ [info] Sending transfer e-mail to ${recipientTransferEmail.from}`);
×
362

363
                                this.sendMail(recipientTransferEmail, "recipient_transfer", () => { /* nop */ });
×
364

365
                                var senderTransferEmail = {
×
366
                                        from: 'THiNX API <api@' + app_config.mailgun.domain + '>',
367
                                        to: body.from,
368
                                        subject: "Device transfer requested",
369
                                        text: "<!DOCTYPE html><p>Hello " + body.from + ".</p>" +
370
                                                "<p> You have requested to transfer following devices to " +
371
                                                body.to +
372
                                                ":</p>" +
373
                                                htmlDeviceList +
374
                                                "<p>You will be notified when your offer will be accepted or declined.</p>" +
375
                                                "</html>"
376
                                };
377

378
                                console.log("ℹ️ [info] Sending transfer e-mail to sender: " + JSON.stringify(senderTransferEmail));
×
379

380
                                /* already responded on line 332, in search of headers sent
381
                                if (process.env.ENVIRONMENT === "test") {
382
                                        return callback(true, transfer_id.replace("dt:", ""));
383
                                } */
384

385
                                this.sendMail(recipientTransferEmail, "recipient_transfer", () => {
×
386
                                        //
387
                                });
388
                        });
389
                });
390
        }
391

392
        save_dtid(tid, keys, ac) {
393
                this.redis.set(tid, JSON.stringify(keys));
×
394
                console.log(`🔨 [debug] [transfer] Accepted udids ${keys.udids}`);
×
395
                if (keys.udids.length > 1) {
×
396
                        ac(true, "transfer_partially_completed");
×
397
                        this.redis.expire(tid, 3600); // 3600 seconds expiration for this transfer request; should be possibly more (like 72h to pass weekends)
×
398
                } else {
399
                        ac(true, "transfer_completed");
×
400
                        this.redis.del(tid);
×
401
                }
402
        }
403

404
        migration_promise(_owner, _list, _rec, _body, _keys) {
405
                return new Promise((resolve) => {
×
406
                        this.migrate_device(_owner, _list, _rec, _body, _keys, () => {
×
407
                                resolve();
×
408
                        });
409
                });
410
        }
411

412
        async accept(body, accept_callback) {
413

414

415
                // minimum body should look like { "transfer_id":"uuid" }
416
                // optional body should look like { "transfer_id":"uuid", "udids" : [ ... ] }
417

418
                if (typeof (body.transfer_id) === "undefined") {
×
419
                        return accept_callback(false, "missing_transfer_id");
×
420
                }
421

422
                var transfer_id = body.transfer_id;
×
423
                var udids = [];
×
424

425
                // Possibly partial transfer but we don't know until count
426
                if (typeof (body.udids) !== "undefined") {
×
427
                        udids = body.udids;
×
428
                }
429

430
                if (typeof (body.udid) !== "undefined") {
×
431
                        udids = body.udid;
×
432
                }
433

434
                const dtid = "dt:" + transfer_id;
×
435

436
                this.redis.get(dtid, (error, encoded_json_keys) => {
×
437

438
                        let keys = JSON.stringify(encoded_json_keys);
×
439

440
                        console.log(`🔨 [debug] [transfer] Fetched DTID: ${dtid} with keys ${{ keys }}`);
×
441

442
                        if (keys.length == 0) {
×
443
                                console.log("⚠️ [warning] [transfer] transfer_id not found (empty response array)");
×
444
                                return accept_callback(false, "transfer_id_not_found");
×
445
                        }
446

447
                        if (encoded_json_keys === null) {
×
448
                                return accept_callback(false, "transfer_id_not_found");
×
449
                        }
450

451
                        var json_keys = JSON.parse(encoded_json_keys);
×
452

453
                        // In case this returns !true (=false), it calls accept_callback on its own.
454
                        if (true !== this.transfer_valid(encoded_json_keys, dtid, accept_callback)) {
×
455
                                return;
×
456
                        }
457

458
                        if (typeof (json_keys.udids) === "undefined") {
×
459
                                json_keys.udids = [];
×
460
                        }
461

462
                        // perform on all devices if udids not given
463
                        console.log(`🔨 [debug] [transfer] L1 udids: ${udids}`);
×
464
                        if ((typeof (udids) !== "undefined") && (udids.length === 0)) udids = json_keys.udids;
×
465

466
                        var recipient_email = json_keys.to;
×
467

468
                        if (typeof (recipient_email) === "undefined" || recipient_email === null) {
×
469
                                return accept_callback(false, "recipient_to_must_be_set");
×
470
                        }
471

472
                        var recipient = sha256(prefix + recipient_email);
×
473
                        var original_owner_email = json_keys.from;
×
474

475
                        if ((typeof (original_owner_email) === "undefined") || (original_owner_email === null)) {
×
476
                                return accept_callback(false, "originator_from_must_be_set");
×
477
                        }
478

479
                        var original_owner = sha256(prefix + original_owner_email);
×
480

481
                        // Check if there are some devices left
482
                        console.log(`🔨 [debug] [transfer] L2 LEFT keys: ${json_keys.udids}`);
×
483
                        if ((typeof (json_keys.udids) !== "undefined") && json_keys.udids.length === 0) {
×
484
                                this.redis.del(dtid);
×
485
                                for (var udid in udids) {
×
486
                                        this.redis.del("dtr:" + udid);
×
487
                                }
488
                                return accept_callback(true, "transfer_completed");
×
489
                        }
490

491
                        let sentence = `Accepting device transfer ${transfer_id} for devices ${JSON.stringify(udids)}`;
×
492
                        alog.log(original_owner, sentence);
×
493
                        alog.log(recipient, sentence);
×
494

495
                        console.log("[OID:" + recipient + "] [TRANSFER_ACCEPT] ", { udids });
×
496

497
                        const locked_udids = udids;
×
498

499
                        let promises = [];
×
500

501
                        for (var dindex in locked_udids) {
×
502
                                let result = this.migration_promise(original_owner, locked_udids[dindex], recipient, body, json_keys);
×
503
                                promises.push(result);
×
504
                        }
505

506
                        Promise.all(promises).then(() => {
×
507
                                this.save_dtid(dtid, json_keys, accept_callback);
×
508
                        })
509
                                .catch(e => console.log("[transfer] promise exception", e));
×
510
                });
511
        }
512

513
        storeRemainingKeys(dtid, json_keys, callback) {
514
                this.redis.set(dtid, JSON.stringify(json_keys));
×
515
                        console.log(`🔨 [debug] [transgfer] L4 Storing remaining keys: ${json_keys.udids}`);
×
516
                        if (json_keys.udids.length > 1) {
×
517
                                // 1 hour to let user accept/decline different devices
518
                                this.redis.expire(dtid, 3600);
×
519
                                callback(true, "transfer_partially_completed");
×
520
                        } else {
521
                                this.redis.del(dtid);
×
522
                                callback(true, "transfer_completed");
×
523
                        }
524
        }
525

526
        decline(body, callback) {
527

528
                // minimum body should look like { "transfer_id":"uuid" }
529
                // optional body should look like { "transfer_id":"uuid", "udids" : [ ... ] }
530

531
                if (typeof (body.transfer_id) === "undefined") {
×
532
                        return callback(false, "missing_transfer_id");
×
533
                }
534

535
                console.log(`[transfer][decline] body: ${JSON.stringify(body)}`);
×
536

537
                var transfer_id = body.transfer_id;
×
538
                var udids = [];
×
539

540
                // Possibly partial transfer but we don't know until count
541
                if ((typeof (body.udids) !== "undefined") && (body.udids !== null)) {
×
542
                        udids = body.udids;
×
543
                }
544

545
                var dtid = "dt:" + transfer_id;
×
546

547
                console.log(`🔨 [debug] [transfer] getting DTID ${dtid} on decline`);
×
548

549
                this.redis.get(dtid, (error, json) => {
×
550

551
                        let json_keys = JSON.parse(json);
×
552

553
                        if (json_keys === []) {
×
554
                                console.log("[transfer] json_keys", json_keys);
×
555
                                return callback(false, "transfer_id_invalid");
×
556
                        }
557

558
                        if (json_keys === null) {
×
559
                                console.log("[transfer] no such transfer anymore");
×
560
                                return callback(true, "decline_complete_no_such_dtid");
×
561
                        }
562

563
                        console.log(`🔨 [debug] [transfer] L5 udids ${udids}`);
×
564

565
                        if ((udids.length === 0) && (typeof (json_keys) !== "undefined")) {
×
566
                                // perform on all devices if udids not given
567
                                udids = json_keys.udids;
×
568
                        }
569

570
                        // Check if there are some devices left
571
                        console.log(`🔨 [debug] [transfer] L6 udids ${json_keys.udids}`);
×
572

573
                        if (json_keys.udids.length == 0) {
×
574
                                this.redis.del(dtid);
×
575
                        }
576

577
                        var recipient_email = json_keys.to;
×
578
                        var recipient = sha256(prefix + recipient_email);
×
579
                        var original_owner_email = json_keys.from;
×
580
                        var original_owner = sha256(prefix + original_owner_email);
×
581

582

583
                        console.log(`🔨 [debug] [transfer] Declining transfer ${transfer_id}`);
×
584

585
                        alog.log(original_owner, "Declining device transfer: " + transfer_id + " for devices: " + JSON.stringify(udids), "warning");
×
586
                        alog.log(recipient, "Declining device transfer: " + transfer_id + " for devices: " + JSON.stringify(udids), "warning");
×
587
                        console.log("[OID:" + recipient + "] [TRANSFER_DECLINE] " + JSON.stringify(udids));
×
588

589
                        for (var dindex in udids) {
×
590
                                var udid = udids[dindex];
×
591
                                delete json_keys.udids[udid];
×
592
                        }
593

594
                        // Store remaining (not declined) keys
595
                        this.storeRemainingKeys(dtid, json_keys, callback);
×
596

597
                        callback(true, "decline_completed");
×
598
                });
599
        }
600

601
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc