piper-utils 1.1.68 → 1.1.70

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "piper-utils",
3
- "version": "1.1.68",
3
+ "version": "1.1.70",
4
4
  "description": "Utility library for Piper",
5
5
  "main": "bin/main.js",
6
6
  "scripts": {
@@ -2,25 +2,35 @@ import _ from 'lodash';
2
2
  import { ensurePartnerScope } from './accessContext.js';
3
3
  import { errorList } from '../../../requestResponse/errorCodes.js';
4
4
 
5
+ // `requested` is the caller-supplied businessIds array (what the route extracts via
6
+ // getRequestedBusinessIds(event)). Empty array = no explicit filter (back-compat path).
7
+ // When non-empty it is intersected against the role's allowed set so a user can never
8
+ // widen their scope past what their role permits by passing a query-string businessId.
9
+
5
10
  /**
6
11
  * Scope a WHERE clause to the partner's own business (partnerBusinessId).
7
12
  * Use for "mine" resources: deal, activity, application, template.
8
13
  *
9
- * global → deletes where.businessId (super sees everything)
10
- * partner → sets where.businessId = partnerBusinessId
14
+ * global → requested ? where.businessId = requested : deletes where.businessId
15
+ * partner → requested ? [partnerBusinessId] ∩ requested : where.businessId = partnerBusinessId
11
16
  * throws partnerNotConfigured if partnerBusinessId is missing
12
17
  * standard → no-op (createFilters already handled)
13
18
  *
14
- * @param {object} where - Sequelize WHERE clause to mutate.
15
- * @param {object} access - Access object from resolveAccess.
19
+ * @param {object} where - Sequelize WHERE clause to mutate.
20
+ * @param {object} access - Access object from resolveAccess.
21
+ * @param {string[]} [requested] - Explicit businessIds from the query string.
16
22
  * @param {{ getPartnerById: Function }} deps
17
23
  */
18
- export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
24
+ export async function scopeToOwnBusiness(where, access, requested = [], { getPartnerById } = {}) {
19
25
  if (!access) {
20
26
  throw errorList.unauthorized;
21
27
  }
22
28
  if (access.level === 'global') {
23
- delete where.businessId;
29
+ if (requested.length) {
30
+ where.businessId = requested;
31
+ } else {
32
+ delete where.businessId;
33
+ }
24
34
  return;
25
35
  }
26
36
  if (access.level === 'partner') {
@@ -28,7 +38,11 @@ export async function scopeToOwnBusiness(where, access, { getPartnerById } = {})
28
38
  if (!access.partnerBusinessId) {
29
39
  throw errorList.partnerNotConfigured;
30
40
  }
31
- where.businessId = access.partnerBusinessId;
41
+ if (requested.length) {
42
+ where.businessId = _.intersection([access.partnerBusinessId], requested);
43
+ } else {
44
+ where.businessId = access.partnerBusinessId;
45
+ }
32
46
  return;
33
47
  }
34
48
  // standard → no-op (createFilters already injected from JWT)
@@ -38,25 +52,32 @@ export async function scopeToOwnBusiness(where, access, { getPartnerById } = {})
38
52
  * Scope a WHERE clause to the partner's portfolio (businessIds array).
39
53
  * Use for "book" resources (future transaction lists, merchant inbox views).
40
54
  *
41
- * global → deletes where.businessId
42
- * partner → sets where.businessId = businessIds[]
55
+ * global → requested ? where.businessId = requested : deletes where.businessId
56
+ * partner → requested ? businessIds ∩ requested : where.businessId = businessIds[]
57
+ * (own business is intentionally excluded — book is portfolio only)
43
58
  * standard → no-op
44
59
  *
45
- * @param {object} where - Sequelize WHERE clause to mutate.
46
- * @param {object} access - Access object from resolveAccess.
60
+ * @param {object} where - Sequelize WHERE clause to mutate.
61
+ * @param {object} access - Access object from resolveAccess.
62
+ * @param {string[]} [requested] - Explicit businessIds from the query string.
47
63
  * @param {{ getPartnerById: Function }} deps
48
64
  */
49
- export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
65
+ export async function scopeToPartnerBook(where, access, requested = [], { getPartnerById } = {}) {
50
66
  if (!access) {
51
67
  throw errorList.unauthorized;
52
68
  }
53
69
  if (access.level === 'global') {
54
- delete where.businessId;
70
+ if (requested.length) {
71
+ where.businessId = requested;
72
+ } else {
73
+ delete where.businessId;
74
+ }
55
75
  return;
56
76
  }
57
77
  if (access.level === 'partner') {
58
78
  await ensurePartnerScope(access, { getPartnerById });
59
- where.businessId = access.businessIds || [];
79
+ const allowed = access.businessIds || [];
80
+ where.businessId = requested.length ? _.intersection(allowed, requested) : allowed;
60
81
  return;
61
82
  }
62
83
  // standard → no-op
@@ -66,34 +87,42 @@ export async function scopeToPartnerBook(where, access, { getPartnerById } = {})
66
87
  * Scope to the union of partnerBusinessId + businessIds.
67
88
  * Use for tickets — partner sees own tickets + portfolio merchants' tickets.
68
89
  *
69
- * global → deletes where.businessId
70
- * partner → sets where.businessId = [partnerBusinessId, ...businessIds]
71
- * standard sets where.businessId = access.businessIds
90
+ * global → requested ? where.businessId = requested : deletes where.businessId
91
+ * partner → allowed = uniq([partnerBusinessId, ...businessIds]);
92
+ * requested ? allowed ∩ requested : where.businessId = allowed
93
+ * standard → allowed = businessIds;
94
+ * requested ? allowed ∩ requested : where.businessId = allowed
72
95
  * (explicitly handled here because ticket routes may not call
73
96
  * createFilters before scoping, e.g. delete/resolve handlers)
74
97
  *
75
- * @param {object} where - Sequelize WHERE clause to mutate.
76
- * @param {object} access - Access object from resolveAccess.
98
+ * @param {object} where - Sequelize WHERE clause to mutate.
99
+ * @param {object} access - Access object from resolveAccess.
100
+ * @param {string[]} [requested] - Explicit businessIds from the query string.
77
101
  * @param {{ getPartnerById: Function }} deps
78
102
  */
79
- export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
103
+ export async function scopeToBookUnionOwn(where, access, requested = [], { getPartnerById } = {}) {
80
104
  if (!access) {
81
105
  throw errorList.unauthorized;
82
106
  }
83
107
  if (access.level === 'global') {
84
- delete where.businessId;
108
+ if (requested.length) {
109
+ where.businessId = requested;
110
+ } else {
111
+ delete where.businessId;
112
+ }
85
113
  return;
86
114
  }
87
115
  if (access.level === 'partner') {
88
116
  await ensurePartnerScope(access, { getPartnerById });
89
- const ids = _.uniq(
117
+ const allowed = _.uniq(
90
118
  [access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
91
119
  );
92
- where.businessId = ids;
120
+ where.businessId = requested.length ? _.intersection(allowed, requested) : allowed;
93
121
  return;
94
122
  }
95
123
  if (access.level === 'standard') {
96
- where.businessId = access.businessIds;
124
+ const allowed = access.businessIds || [];
125
+ where.businessId = requested.length ? _.intersection(allowed, requested) : allowed;
97
126
  return;
98
127
  }
99
128
  }
@@ -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, requested) => scopeToOwnBusiness(where, access, requested, deps),
31
+ scopeToPartnerBook: (where, access, requested) => scopeToPartnerBook(where, access, requested, deps),
32
+ scopeToBookUnionOwn: (where, access, requested) => scopeToBookUnionOwn(where, access, requested, 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
+ }