piper-utils 1.1.68 → 1.1.69
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/bin/main.js +4 -2
- package/bin/main.js.map +1 -1
- package/package.json +1 -1
- package/src/database/dbUtils/partnerAccess/accessScope.js +99 -99
- package/src/database/dbUtils/partnerAccess/accessScope.test.js +287 -0
- package/src/database/dbUtils/partnerAccess/createAccessHelpers.js +38 -38
- package/src/requestResponse/requestResponse.js +6 -1
package/package.json
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
import _ from 'lodash';
|
|
2
|
-
import { ensurePartnerScope } from './accessContext.js';
|
|
3
|
-
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Scope a WHERE clause to the partner's own business (partnerBusinessId).
|
|
7
|
-
* Use for "mine" resources: deal, activity, application, template.
|
|
8
|
-
*
|
|
9
|
-
* global → deletes where.businessId (super sees everything)
|
|
10
|
-
* partner → sets where.businessId = partnerBusinessId
|
|
11
|
-
* throws partnerNotConfigured if partnerBusinessId is missing
|
|
12
|
-
* standard → no-op (createFilters already handled)
|
|
13
|
-
*
|
|
14
|
-
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
15
|
-
* @param {object} access - Access object from resolveAccess.
|
|
16
|
-
* @param {{ getPartnerById: Function }} deps
|
|
17
|
-
*/
|
|
18
|
-
export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
|
|
19
|
-
if (!access) {
|
|
20
|
-
throw errorList.unauthorized;
|
|
21
|
-
}
|
|
22
|
-
if (access.level === 'global') {
|
|
23
|
-
delete where.businessId;
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
if (access.level === 'partner') {
|
|
27
|
-
await ensurePartnerScope(access, { getPartnerById });
|
|
28
|
-
if (!access.partnerBusinessId) {
|
|
29
|
-
throw errorList.partnerNotConfigured;
|
|
30
|
-
}
|
|
31
|
-
where.businessId = access.partnerBusinessId;
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
// standard → no-op (createFilters already injected from JWT)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Scope a WHERE clause to the partner's portfolio (businessIds array).
|
|
39
|
-
* Use for "book" resources (future transaction lists, merchant inbox views).
|
|
40
|
-
*
|
|
41
|
-
* global → deletes where.businessId
|
|
42
|
-
* partner → sets where.businessId = businessIds[]
|
|
43
|
-
* standard → no-op
|
|
44
|
-
*
|
|
45
|
-
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
46
|
-
* @param {object} access - Access object from resolveAccess.
|
|
47
|
-
* @param {{ getPartnerById: Function }} deps
|
|
48
|
-
*/
|
|
49
|
-
export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
|
|
50
|
-
if (!access) {
|
|
51
|
-
throw errorList.unauthorized;
|
|
52
|
-
}
|
|
53
|
-
if (access.level === 'global') {
|
|
54
|
-
delete where.businessId;
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (access.level === 'partner') {
|
|
58
|
-
await ensurePartnerScope(access, { getPartnerById });
|
|
59
|
-
where.businessId = access.businessIds || [];
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
// standard → no-op
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Scope to the union of partnerBusinessId + businessIds.
|
|
67
|
-
* Use for tickets — partner sees own tickets + portfolio merchants' tickets.
|
|
68
|
-
*
|
|
69
|
-
* global → deletes where.businessId
|
|
70
|
-
* partner → sets where.businessId = [partnerBusinessId, ...businessIds]
|
|
71
|
-
* standard → sets where.businessId = access.businessIds
|
|
72
|
-
* (explicitly handled here because ticket routes may not call
|
|
73
|
-
* createFilters before scoping, e.g. delete/resolve handlers)
|
|
74
|
-
*
|
|
75
|
-
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
76
|
-
* @param {object} access - Access object from resolveAccess.
|
|
77
|
-
* @param {{ getPartnerById: Function }} deps
|
|
78
|
-
*/
|
|
79
|
-
export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
|
|
80
|
-
if (!access) {
|
|
81
|
-
throw errorList.unauthorized;
|
|
82
|
-
}
|
|
83
|
-
if (access.level === 'global') {
|
|
84
|
-
delete where.businessId;
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (access.level === 'partner') {
|
|
88
|
-
await ensurePartnerScope(access, { getPartnerById });
|
|
89
|
-
const ids = _.uniq(
|
|
90
|
-
[access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
|
|
91
|
-
);
|
|
92
|
-
where.businessId = ids;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (access.level === 'standard') {
|
|
96
|
-
where.businessId = access.businessIds;
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import { ensurePartnerScope } from './accessContext.js';
|
|
3
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scope a WHERE clause to the partner's own business (partnerBusinessId).
|
|
7
|
+
* Use for "mine" resources: deal, activity, application, template.
|
|
8
|
+
*
|
|
9
|
+
* global → deletes where.businessId (super sees everything)
|
|
10
|
+
* partner → sets where.businessId = partnerBusinessId
|
|
11
|
+
* throws partnerNotConfigured if partnerBusinessId is missing
|
|
12
|
+
* standard → no-op (createFilters already handled)
|
|
13
|
+
*
|
|
14
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
15
|
+
* @param {object} access - Access object from resolveAccess.
|
|
16
|
+
* @param {{ getPartnerById: Function }} deps
|
|
17
|
+
*/
|
|
18
|
+
export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
|
|
19
|
+
if (!access) {
|
|
20
|
+
throw errorList.unauthorized;
|
|
21
|
+
}
|
|
22
|
+
if (access.level === 'global') {
|
|
23
|
+
delete where.businessId;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (access.level === 'partner') {
|
|
27
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
28
|
+
if (!access.partnerBusinessId) {
|
|
29
|
+
throw errorList.partnerNotConfigured;
|
|
30
|
+
}
|
|
31
|
+
where.businessId = access.partnerBusinessId;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// standard → no-op (createFilters already injected from JWT)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scope a WHERE clause to the partner's portfolio (businessIds array).
|
|
39
|
+
* Use for "book" resources (future transaction lists, merchant inbox views).
|
|
40
|
+
*
|
|
41
|
+
* global → deletes where.businessId
|
|
42
|
+
* partner → sets where.businessId = businessIds[]
|
|
43
|
+
* standard → no-op
|
|
44
|
+
*
|
|
45
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
46
|
+
* @param {object} access - Access object from resolveAccess.
|
|
47
|
+
* @param {{ getPartnerById: Function }} deps
|
|
48
|
+
*/
|
|
49
|
+
export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
|
|
50
|
+
if (!access) {
|
|
51
|
+
throw errorList.unauthorized;
|
|
52
|
+
}
|
|
53
|
+
if (access.level === 'global') {
|
|
54
|
+
delete where.businessId;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (access.level === 'partner') {
|
|
58
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
59
|
+
where.businessId = access.businessIds || [];
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// standard → no-op
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scope to the union of partnerBusinessId + businessIds.
|
|
67
|
+
* Use for tickets — partner sees own tickets + portfolio merchants' tickets.
|
|
68
|
+
*
|
|
69
|
+
* global → deletes where.businessId
|
|
70
|
+
* partner → sets where.businessId = [partnerBusinessId, ...businessIds]
|
|
71
|
+
* standard → sets where.businessId = access.businessIds
|
|
72
|
+
* (explicitly handled here because ticket routes may not call
|
|
73
|
+
* createFilters before scoping, e.g. delete/resolve handlers)
|
|
74
|
+
*
|
|
75
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
76
|
+
* @param {object} access - Access object from resolveAccess.
|
|
77
|
+
* @param {{ getPartnerById: Function }} deps
|
|
78
|
+
*/
|
|
79
|
+
export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
|
|
80
|
+
if (!access) {
|
|
81
|
+
throw errorList.unauthorized;
|
|
82
|
+
}
|
|
83
|
+
if (access.level === 'global') {
|
|
84
|
+
delete where.businessId;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (access.level === 'partner') {
|
|
88
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
89
|
+
const ids = _.uniq(
|
|
90
|
+
[access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
|
|
91
|
+
);
|
|
92
|
+
where.businessId = ids;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (access.level === 'standard') {
|
|
96
|
+
where.businessId = access.businessIds;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scopeToOwnBusiness,
|
|
3
|
+
scopeToPartnerBook,
|
|
4
|
+
scopeToBookUnionOwn
|
|
5
|
+
} from './accessScope.js';
|
|
6
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
7
|
+
|
|
8
|
+
// Why these tests exist
|
|
9
|
+
// ---------------------
|
|
10
|
+
// The three scope helpers were rewritten to accept an explicit `requested`
|
|
11
|
+
// businessIds array from the caller's query string, and to intersect that
|
|
12
|
+
// against the role's allowed set instead of unconditionally overwriting
|
|
13
|
+
// where.businessId. The header "My Business" filter for super users depends
|
|
14
|
+
// on the new intersection — these tests pin the contract.
|
|
15
|
+
//
|
|
16
|
+
// "requested" is what the route handler extracts via getRequestedBusinessIds(event)
|
|
17
|
+
// and forwards. Empty array = no explicit filter (back-compat path).
|
|
18
|
+
|
|
19
|
+
describe('accessScope helpers', () => {
|
|
20
|
+
|
|
21
|
+
describe('scopeToBookUnionOwn — global (super)', () => {
|
|
22
|
+
it('deletes where.businessId when no explicit filter is requested', async () => {
|
|
23
|
+
// Arrange — super with no header pick → caller sends nothing
|
|
24
|
+
const where = { businessId: 'pre-existing-set-by-createFilters', active: true };
|
|
25
|
+
const access = { level: 'global' };
|
|
26
|
+
|
|
27
|
+
// Act
|
|
28
|
+
await scopeToBookUnionOwn(where, access, []);
|
|
29
|
+
|
|
30
|
+
// Assert — businessId stripped so the query is unconstrained;
|
|
31
|
+
// unrelated keys (active) survive
|
|
32
|
+
expect(where.businessId).toBeUndefined();
|
|
33
|
+
expect(where.active).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('honors the requested businessIds when super passes them explicitly', async () => {
|
|
37
|
+
// Arrange — super picked business "X" in the header pill
|
|
38
|
+
const where = {};
|
|
39
|
+
const access = { level: 'global' };
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
await scopeToBookUnionOwn(where, access, ['X']);
|
|
43
|
+
|
|
44
|
+
// Assert — backend respects the explicit filter (the symptom from
|
|
45
|
+
// the user-reported "Deals list doesn't filter to My Business" bug)
|
|
46
|
+
expect(where.businessId).toEqual(['X']);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('scopeToBookUnionOwn — partner', () => {
|
|
51
|
+
it('returns the full union when no explicit filter is requested', async () => {
|
|
52
|
+
// Arrange — partner with own=O and portfolio=[A, B]; pre-resolved scope
|
|
53
|
+
const where = {};
|
|
54
|
+
const access = {
|
|
55
|
+
level: 'partner',
|
|
56
|
+
partnerBusinessId: 'O',
|
|
57
|
+
businessIds: ['A', 'B']
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
await scopeToBookUnionOwn(where, access, []);
|
|
62
|
+
|
|
63
|
+
// Assert — union preserved (own + portfolio, unique)
|
|
64
|
+
expect(where.businessId).toEqual(['O', 'A', 'B']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('intersects requested with the union when partner filters explicitly', async () => {
|
|
68
|
+
// Arrange — partner asks for A only, A is in portfolio
|
|
69
|
+
const where = {};
|
|
70
|
+
const access = {
|
|
71
|
+
level: 'partner',
|
|
72
|
+
partnerBusinessId: 'O',
|
|
73
|
+
businessIds: ['A', 'B']
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
await scopeToBookUnionOwn(where, access, ['A']);
|
|
78
|
+
|
|
79
|
+
// Assert — only the requested+allowed entry survives
|
|
80
|
+
expect(where.businessId).toEqual(['A']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns empty when requested business is outside the partner union', async () => {
|
|
84
|
+
// Arrange — partner asks for Z which is not in own or portfolio
|
|
85
|
+
const where = {};
|
|
86
|
+
const access = {
|
|
87
|
+
level: 'partner',
|
|
88
|
+
partnerBusinessId: 'O',
|
|
89
|
+
businessIds: ['A', 'B']
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
await scopeToBookUnionOwn(where, access, ['Z']);
|
|
94
|
+
|
|
95
|
+
// Assert — empty array → IN () → silent zero rows. Intentional;
|
|
96
|
+
// confirms a partner can't sneak past their scope via query param.
|
|
97
|
+
expect(where.businessId).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('lazy-loads partnerBusinessId/businessIds via getPartnerById on first call', async () => {
|
|
101
|
+
// Arrange — access is "fresh" (nulls), like resolveAccess hands out
|
|
102
|
+
const where = {};
|
|
103
|
+
const access = { level: 'partner', partnerId: 'p1', partnerBusinessId: null, businessIds: null };
|
|
104
|
+
const getPartnerById = jasmine.createSpy('getPartnerById').and.resolveTo({
|
|
105
|
+
partnerBusinessId: 'O',
|
|
106
|
+
businessIds: ['A']
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
await scopeToBookUnionOwn(where, access, [], { getPartnerById });
|
|
111
|
+
|
|
112
|
+
// Assert — DB hit happened and where.businessId is the resolved union
|
|
113
|
+
expect(getPartnerById).toHaveBeenCalledWith('p1');
|
|
114
|
+
expect(where.businessId).toEqual(['O', 'A']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('scopeToBookUnionOwn — standard (merchant)', () => {
|
|
119
|
+
it('falls back to access.businessIds when no explicit filter is requested', async () => {
|
|
120
|
+
// Arrange — standard merchant has JWT businesses S1, S2
|
|
121
|
+
const where = {};
|
|
122
|
+
const access = { level: 'standard', businessIds: ['S1', 'S2'] };
|
|
123
|
+
|
|
124
|
+
// Act
|
|
125
|
+
await scopeToBookUnionOwn(where, access, []);
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
128
|
+
expect(where.businessId).toEqual(['S1', 'S2']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('intersects requested with JWT businesses when standard filters explicitly', async () => {
|
|
132
|
+
// Arrange — merchant asks for S1 (in their JWT)
|
|
133
|
+
const where = {};
|
|
134
|
+
const access = { level: 'standard', businessIds: ['S1', 'S2'] };
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
await scopeToBookUnionOwn(where, access, ['S1']);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
140
|
+
expect(where.businessId).toEqual(['S1']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns empty when standard asks for a business outside their JWT', async () => {
|
|
144
|
+
// Arrange — IDOR attempt: merchant on S1 asks for someone else's business "X"
|
|
145
|
+
const where = {};
|
|
146
|
+
const access = { level: 'standard', businessIds: ['S1', 'S2'] };
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
await scopeToBookUnionOwn(where, access, ['X']);
|
|
150
|
+
|
|
151
|
+
// Assert — empty result, no cross-tenant leak
|
|
152
|
+
expect(where.businessId).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('scopeToBookUnionOwn — defensive', () => {
|
|
157
|
+
it('throws unauthorized when access is null', async () => {
|
|
158
|
+
// Arrange — never expected to happen, but guard rather than corrupt the query
|
|
159
|
+
let thrown;
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
try {
|
|
163
|
+
await scopeToBookUnionOwn({}, null, []);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
thrown = e;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(thrown).toBe(errorList.unauthorized);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('scopeToOwnBusiness', () => {
|
|
174
|
+
it('sets where.businessId to partnerBusinessId for partners (no explicit filter)', async () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const where = {};
|
|
177
|
+
const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
await scopeToOwnBusiness(where, access, []);
|
|
181
|
+
|
|
182
|
+
// Assert — own resource scope is single-value, not union
|
|
183
|
+
expect(where.businessId).toBe('O');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('intersects requested with the partner own businessId', async () => {
|
|
187
|
+
// Arrange — partner asks for "O" (their own) — valid
|
|
188
|
+
const where = {};
|
|
189
|
+
const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
|
|
190
|
+
|
|
191
|
+
// Act
|
|
192
|
+
await scopeToOwnBusiness(where, access, ['O']);
|
|
193
|
+
|
|
194
|
+
// Assert — intersection ['O'] ∩ ['O'] = ['O']
|
|
195
|
+
expect(where.businessId).toEqual(['O']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns empty when requested business is not the partner own', async () => {
|
|
199
|
+
// Arrange — partner asks for portfolio business "A" but this is the OWN scope
|
|
200
|
+
const where = {};
|
|
201
|
+
const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
|
|
202
|
+
|
|
203
|
+
// Act
|
|
204
|
+
await scopeToOwnBusiness(where, access, ['A']);
|
|
205
|
+
|
|
206
|
+
// Assert — own scope rejects even valid-for-them businesses outside "own"
|
|
207
|
+
expect(where.businessId).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('throws partnerNotConfigured when partnerBusinessId is missing', async () => {
|
|
211
|
+
// Arrange — bad partner setup: portfolio set but no own businessId.
|
|
212
|
+
// Threw originally too, but now after the new requested param flows through.
|
|
213
|
+
const where = {};
|
|
214
|
+
const access = { level: 'partner', partnerBusinessId: null, businessIds: ['A'] };
|
|
215
|
+
let thrown;
|
|
216
|
+
|
|
217
|
+
// Act
|
|
218
|
+
try {
|
|
219
|
+
await scopeToOwnBusiness(where, access, []);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
thrown = e;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Assert
|
|
225
|
+
expect(thrown).toBe(errorList.partnerNotConfigured);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('global with explicit request → where.businessId = requested', async () => {
|
|
229
|
+
// Arrange
|
|
230
|
+
const where = {};
|
|
231
|
+
|
|
232
|
+
// Act
|
|
233
|
+
await scopeToOwnBusiness(where, { level: 'global' }, ['X']);
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
236
|
+
expect(where.businessId).toEqual(['X']);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('global with no request → where.businessId deleted', async () => {
|
|
240
|
+
// Arrange
|
|
241
|
+
const where = { businessId: 'stale' };
|
|
242
|
+
|
|
243
|
+
// Act
|
|
244
|
+
await scopeToOwnBusiness(where, { level: 'global' }, []);
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
expect(where.businessId).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('scopeToPartnerBook', () => {
|
|
252
|
+
it('returns full portfolio for partners with no explicit request', async () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
const where = {};
|
|
255
|
+
const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A', 'B'] };
|
|
256
|
+
|
|
257
|
+
// Act
|
|
258
|
+
await scopeToPartnerBook(where, access, []);
|
|
259
|
+
|
|
260
|
+
// Assert — book is portfolio only (no own)
|
|
261
|
+
expect(where.businessId).toEqual(['A', 'B']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('intersects requested with portfolio (excludes partner own)', async () => {
|
|
265
|
+
// Arrange — partner asks for "O" (own) and "A" (portfolio)
|
|
266
|
+
const where = {};
|
|
267
|
+
const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A', 'B'] };
|
|
268
|
+
|
|
269
|
+
// Act
|
|
270
|
+
await scopeToPartnerBook(where, access, ['O', 'A']);
|
|
271
|
+
|
|
272
|
+
// Assert — book scope drops "own" even when explicitly requested
|
|
273
|
+
expect(where.businessId).toEqual(['A']);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('global no request → deletes where.businessId', async () => {
|
|
277
|
+
// Arrange
|
|
278
|
+
const where = { businessId: 'stale' };
|
|
279
|
+
|
|
280
|
+
// Act
|
|
281
|
+
await scopeToPartnerBook(where, { level: 'global' }, []);
|
|
282
|
+
|
|
283
|
+
// Assert
|
|
284
|
+
expect(where.businessId).toBeUndefined();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { ensurePartnerScope } from './accessContext.js';
|
|
2
|
-
import { scopeToOwnBusiness, scopeToPartnerBook, scopeToBookUnionOwn } from './accessScope.js';
|
|
3
|
-
import {
|
|
4
|
-
assertCanWriteOwnBusiness,
|
|
5
|
-
assertCanWriteBookBusiness,
|
|
6
|
-
assertCanWriteBookUnionOwn,
|
|
7
|
-
stampOwnBusinessId
|
|
8
|
-
} from './accessWrites.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Factory that pre-binds getPartnerById into all scope/assert/stamp helpers.
|
|
12
|
-
* Bind once at module level, then call without repeating the injection:
|
|
13
|
-
*
|
|
14
|
-
* import { createAccessHelpers } from 'piper-utils';
|
|
15
|
-
* import { getPartnerById } from '../partner/partner.js';
|
|
16
|
-
*
|
|
17
|
-
* const {
|
|
18
|
-
* scopeToOwnBusiness,
|
|
19
|
-
* stampOwnBusinessId,
|
|
20
|
-
* assertCanWriteOwnBusiness
|
|
21
|
-
* } = createAccessHelpers({ getPartnerById });
|
|
22
|
-
*
|
|
23
|
-
* @param {{ getPartnerById: Function }} deps
|
|
24
|
-
* @returns {object} Pre-bound helpers.
|
|
25
|
-
*/
|
|
26
|
-
export function createAccessHelpers({ getPartnerById }) {
|
|
27
|
-
const deps = { getPartnerById };
|
|
28
|
-
return {
|
|
29
|
-
ensurePartnerScope: (access) => ensurePartnerScope(access, deps),
|
|
30
|
-
scopeToOwnBusiness: (where, access) => scopeToOwnBusiness(where, access, deps),
|
|
31
|
-
scopeToPartnerBook: (where, access) => scopeToPartnerBook(where, access, deps),
|
|
32
|
-
scopeToBookUnionOwn: (where, access) => scopeToBookUnionOwn(where, access, deps),
|
|
33
|
-
assertCanWriteOwnBusiness: (access, businessId) => assertCanWriteOwnBusiness(access, businessId, deps),
|
|
34
|
-
assertCanWriteBookBusiness: (access, businessId) => assertCanWriteBookBusiness(access, businessId, deps),
|
|
35
|
-
assertCanWriteBookUnionOwn: (access, businessId) => assertCanWriteBookUnionOwn(access, businessId, deps),
|
|
36
|
-
stampOwnBusinessId: (access, body) => stampOwnBusinessId(access, body, deps)
|
|
37
|
-
};
|
|
38
|
-
}
|
|
1
|
+
import { ensurePartnerScope } from './accessContext.js';
|
|
2
|
+
import { scopeToOwnBusiness, scopeToPartnerBook, scopeToBookUnionOwn } from './accessScope.js';
|
|
3
|
+
import {
|
|
4
|
+
assertCanWriteOwnBusiness,
|
|
5
|
+
assertCanWriteBookBusiness,
|
|
6
|
+
assertCanWriteBookUnionOwn,
|
|
7
|
+
stampOwnBusinessId
|
|
8
|
+
} from './accessWrites.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Factory that pre-binds getPartnerById into all scope/assert/stamp helpers.
|
|
12
|
+
* Bind once at module level, then call without repeating the injection:
|
|
13
|
+
*
|
|
14
|
+
* import { createAccessHelpers } from 'piper-utils';
|
|
15
|
+
* import { getPartnerById } from '../partner/partner.js';
|
|
16
|
+
*
|
|
17
|
+
* const {
|
|
18
|
+
* scopeToOwnBusiness,
|
|
19
|
+
* stampOwnBusinessId,
|
|
20
|
+
* assertCanWriteOwnBusiness
|
|
21
|
+
* } = createAccessHelpers({ getPartnerById });
|
|
22
|
+
*
|
|
23
|
+
* @param {{ getPartnerById: Function }} deps
|
|
24
|
+
* @returns {object} Pre-bound helpers.
|
|
25
|
+
*/
|
|
26
|
+
export function createAccessHelpers({ getPartnerById }) {
|
|
27
|
+
const deps = { getPartnerById };
|
|
28
|
+
return {
|
|
29
|
+
ensurePartnerScope: (access) => ensurePartnerScope(access, deps),
|
|
30
|
+
scopeToOwnBusiness: (where, access) => scopeToOwnBusiness(where, access, deps),
|
|
31
|
+
scopeToPartnerBook: (where, access) => scopeToPartnerBook(where, access, deps),
|
|
32
|
+
scopeToBookUnionOwn: (where, access) => scopeToBookUnionOwn(where, access, deps),
|
|
33
|
+
assertCanWriteOwnBusiness: (access, businessId) => assertCanWriteOwnBusiness(access, businessId, deps),
|
|
34
|
+
assertCanWriteBookBusiness: (access, businessId) => assertCanWriteBookBusiness(access, businessId, deps),
|
|
35
|
+
assertCanWriteBookUnionOwn: (access, businessId) => assertCanWriteBookUnionOwn(access, businessId, deps),
|
|
36
|
+
stampOwnBusinessId: (access, body) => stampOwnBusinessId(access, body, deps)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -125,6 +125,12 @@ export function failure(body = {}, options) {
|
|
|
125
125
|
}
|
|
126
126
|
const newBody = _.merge(NORMAL_ERROR, cleanedErrorBody);
|
|
127
127
|
|
|
128
|
+
const debugLogging = process.env.UTIL_LOG === 'LOG_ALL' || process.env.BUILD_ENV === 'test';
|
|
129
|
+
if (!debugLogging && newBody.statusCode >= 500) {
|
|
130
|
+
// Log the raw error object so thrown Error stacks survive (JSON.stringify drops them)
|
|
131
|
+
console.error('------->UTIL ERROR:', newBody.statusCode, _.get(body, 'message', ''), body);
|
|
132
|
+
}
|
|
133
|
+
|
|
128
134
|
return buildResponse(newBody.statusCode, newBody);
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -266,7 +272,6 @@ export function detectJoyError(body) {
|
|
|
266
272
|
return acc;
|
|
267
273
|
}, '');
|
|
268
274
|
|
|
269
|
-
console.error('USER VALIDATION ERROR:', body);
|
|
270
275
|
const msg = (joyError?.message || '') + v;
|
|
271
276
|
|
|
272
277
|
if (msg) {
|