@dhyasama/totem-merge 7.1.2 → 7.1.3
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,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Types: { ObjectId } } = require('mongoose');
|
|
4
|
+
|
|
5
|
+
// Use Organization's own connection, including for callers that have not
|
|
6
|
+
// registered the additive Customer model. Never filter on active/premium:
|
|
7
|
+
// inactive tenant identities must remain protected too.
|
|
8
|
+
module.exports = function customerMergeGuard(Organization, target, source, cb) {
|
|
9
|
+
const blocked = () => new Error('Customer organizations cannot be merged');
|
|
10
|
+
if ([target, source].some(org => org && org.customer && org.customer.totemUrl)) {
|
|
11
|
+
return cb(blocked(), null);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let lookup;
|
|
15
|
+
try {
|
|
16
|
+
const ids = [target, source].map(org => {
|
|
17
|
+
const id = org && (org._id || org.id);
|
|
18
|
+
if (!id || !/^[a-f0-9]{24}$/i.test(String(id))) {
|
|
19
|
+
throw new Error('A valid organization ID is required before merging');
|
|
20
|
+
}
|
|
21
|
+
return new ObjectId(String(id));
|
|
22
|
+
});
|
|
23
|
+
lookup = Organization.db.collection('customers').findOne({
|
|
24
|
+
$or: [{ _id: { $in: ids } }, { organization: { $in: ids } }]
|
|
25
|
+
}, { projection: { _id: 1 } });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return cb(err, null);
|
|
28
|
+
}
|
|
29
|
+
// Separate success and rejection handlers so a consumer callback exception
|
|
30
|
+
// cannot cause a second callback invocation.
|
|
31
|
+
return Promise.resolve(lookup).then(
|
|
32
|
+
customer => cb(customer ? blocked() : null, null),
|
|
33
|
+
err => cb(err, null)
|
|
34
|
+
);
|
|
35
|
+
};
|
package/lib/organizations.js
CHANGED
|
@@ -5,6 +5,7 @@ let _ = require('underscore');
|
|
|
5
5
|
let helpers = require('./helpers');
|
|
6
6
|
let mongoose = require('mongoose');
|
|
7
7
|
let ObjectId = mongoose.Types.ObjectId;
|
|
8
|
+
let verifyCustomerMerge = require('./customerMergeGuard');
|
|
8
9
|
let logger;
|
|
9
10
|
|
|
10
11
|
var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
@@ -38,7 +39,7 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
38
39
|
|
|
39
40
|
async.series([
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
verifyCustomerMerge.bind(null, context.Organization, target, source),
|
|
42
43
|
|
|
43
44
|
// Merge the source into the target
|
|
44
45
|
mergeSourceIntoTarget.bind(null, target, source),
|
|
@@ -113,21 +114,6 @@ var mergeSourceIntoTarget = function mergeEntities(target, source, cb) {
|
|
|
113
114
|
|
|
114
115
|
};
|
|
115
116
|
|
|
116
|
-
var verifyOkToMerge = function verifyOkToMerge(target, source, cb) {
|
|
117
|
-
|
|
118
|
-
logger.writeToInfoLog('Verify ok to merge...');
|
|
119
|
-
|
|
120
|
-
// Cannot merge if target is a customer
|
|
121
|
-
if (target && target.customer && target.customer.totemUrl) { return cb(true, null); }
|
|
122
|
-
|
|
123
|
-
// Cannot merge if source is a customer
|
|
124
|
-
else if (source && source.customer && source.customer.totemUrl) { return cb(true, null); }
|
|
125
|
-
|
|
126
|
-
// Ok to merge
|
|
127
|
-
else { return cb(null, null); }
|
|
128
|
-
|
|
129
|
-
};
|
|
130
|
-
|
|
131
117
|
var updateReferences = function updateReferences(context, target, source, mergedBy, customerId, cb) {
|
|
132
118
|
|
|
133
119
|
logger.writeToInfoLog('Update references...');
|
|
@@ -455,4 +441,4 @@ var updateRounds = function updateRounds(newOrgId, oldOrgId, cb) {
|
|
|
455
441
|
|
|
456
442
|
};
|
|
457
443
|
|
|
458
|
-
module.exports.merge = merge;
|
|
444
|
+
module.exports.merge = merge;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhyasama/totem-merge",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.3",
|
|
4
4
|
"description": "Org and person merging package",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"mongoose": "^8.24.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@dhyasama/totem-models": "
|
|
30
|
+
"@dhyasama/totem-models": "15.1.0",
|
|
31
31
|
"mocha": "^12.0.2",
|
|
32
32
|
"mocha-mongoose": "^1.1.1",
|
|
33
33
|
"should": "^8.2.2"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const mongoose = require('mongoose');
|
|
5
|
+
const guard = require('../lib/customerMergeGuard');
|
|
6
|
+
const uri = process.env.CUSTOMER_MERGE_TEST_URI;
|
|
7
|
+
|
|
8
|
+
(uri ? describe : describe.skip)('customer merge guard with MongoDB', function() {
|
|
9
|
+
let connection, Organization, customers;
|
|
10
|
+
before(async function() {
|
|
11
|
+
if (!/^mongodb:\/\/127\.0\.0\.1:\d+\/customer_merge_guard_test(?:\?|$)/.test(uri)) {
|
|
12
|
+
throw new Error('Only the local customer_merge_guard_test database is allowed');
|
|
13
|
+
}
|
|
14
|
+
connection = await mongoose.createConnection(uri).asPromise();
|
|
15
|
+
Organization = connection.model('MergeGuardOrganization', new mongoose.Schema({ name: String }));
|
|
16
|
+
customers = connection.collection('customers');
|
|
17
|
+
});
|
|
18
|
+
after(async function() { if (connection) await connection.close(); });
|
|
19
|
+
|
|
20
|
+
it('blocks either tenant identity including inactive records and permits shared companies', async function() {
|
|
21
|
+
const target = new Organization({ name: 'Synthetic target' });
|
|
22
|
+
const source = new Organization({ name: 'Synthetic source' });
|
|
23
|
+
const run = () => new Promise(resolve => guard(Organization, target, source, resolve));
|
|
24
|
+
try {
|
|
25
|
+
assert.equal(await run(), null);
|
|
26
|
+
for (const org of [source, target]) {
|
|
27
|
+
await customers.insertOne({ _id: org._id, organization: org._id, settings: { active: false } });
|
|
28
|
+
assert.match((await run()).message, /cannot be merged/);
|
|
29
|
+
await customers.deleteOne({ _id: org._id });
|
|
30
|
+
}
|
|
31
|
+
assert.equal(await run(), null);
|
|
32
|
+
} finally {
|
|
33
|
+
await customers.deleteMany({ _id: { $in: [target._id, source._id] } });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { Types: { ObjectId } } = require('mongoose');
|
|
5
|
+
const guard = require('../lib/customerMergeGuard');
|
|
6
|
+
const merge = require('../lib/organizations').merge;
|
|
7
|
+
|
|
8
|
+
function fixture(records = [], failure) {
|
|
9
|
+
const target = { _id: new ObjectId() };
|
|
10
|
+
const source = { _id: new ObjectId() };
|
|
11
|
+
const calls = [];
|
|
12
|
+
const Organization = { db: { collection(name) {
|
|
13
|
+
assert.equal(name, 'customers');
|
|
14
|
+
return { async findOne(query, options) {
|
|
15
|
+
calls.push(query);
|
|
16
|
+
assert.deepEqual(options, { projection: { _id: 1 } });
|
|
17
|
+
if (failure) throw failure;
|
|
18
|
+
return records.find(record => query.$or.some(clause => {
|
|
19
|
+
const key = Object.keys(clause)[0];
|
|
20
|
+
return clause[key].$in.some(id => String(id) === String(record[key]));
|
|
21
|
+
})) || null;
|
|
22
|
+
} };
|
|
23
|
+
} } };
|
|
24
|
+
const run = () => new Promise(resolve => guard(Organization, target, source, err => resolve(err)));
|
|
25
|
+
return { target, source, Organization, calls, run, records };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('customer organization merge guard', function() {
|
|
29
|
+
for (const side of ['target', 'source']) {
|
|
30
|
+
for (const active of [true, false]) {
|
|
31
|
+
it(`blocks ${active ? 'active' : 'inactive'} Customer on ${side} without legacy settings`, async function() {
|
|
32
|
+
const f = fixture();
|
|
33
|
+
f.records.push({ _id: f[side]._id, settings: { active } });
|
|
34
|
+
assert.match((await f.run()).message, /cannot be merged/);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
it(`preserves the legacy ${side} guard without a database lookup`, async function() {
|
|
38
|
+
const f = fixture();
|
|
39
|
+
f[side].customer = { totemUrl: 'fixture' };
|
|
40
|
+
assert.match((await f.run()).message, /cannot be merged/);
|
|
41
|
+
assert.equal(f.calls.length, 0);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
it('also protects the organization reference if a Customer id is inconsistent', async function() {
|
|
45
|
+
const f = fixture();
|
|
46
|
+
f.records.push({ _id: new ObjectId(), organization: f.source._id });
|
|
47
|
+
assert.match((await f.run()).message, /cannot be merged/);
|
|
48
|
+
});
|
|
49
|
+
it('allows two shared organizations without Customer records', async function() {
|
|
50
|
+
const f = fixture();
|
|
51
|
+
assert.equal(await f.run(), null);
|
|
52
|
+
assert.equal(f.calls.length, 1);
|
|
53
|
+
});
|
|
54
|
+
it('fails closed when the lookup fails', async function() {
|
|
55
|
+
const failure = new Error('Database unavailable');
|
|
56
|
+
assert.equal(await fixture([], failure).run(), failure);
|
|
57
|
+
});
|
|
58
|
+
it('fails closed before querying an invalid identity', async function() {
|
|
59
|
+
const f = fixture();
|
|
60
|
+
f.source._id = 'invalid';
|
|
61
|
+
assert.match((await f.run()).message, /valid organization ID/);
|
|
62
|
+
assert.equal(f.calls.length, 0);
|
|
63
|
+
});
|
|
64
|
+
it('blocks the public merge pipeline before mutation or deletion', async function() {
|
|
65
|
+
const f = fixture();
|
|
66
|
+
f.records.push({ _id: f.source._id });
|
|
67
|
+
const before = JSON.stringify([f.target, f.source]);
|
|
68
|
+
const context = {
|
|
69
|
+
Organization: f.Organization,
|
|
70
|
+
logger: { writeToInfoLog() {} }
|
|
71
|
+
};
|
|
72
|
+
const err = await new Promise(resolve => merge(context, f.target, f.source, 'test', new ObjectId(), resolve));
|
|
73
|
+
assert.match(err.message, /cannot be merged/);
|
|
74
|
+
assert.equal(JSON.stringify([f.target, f.source]), before);
|
|
75
|
+
});
|
|
76
|
+
});
|