@memberjunction/server 5.51.0 → 5.51.2

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.
Files changed (39) hide show
  1. package/dist/auth/index.d.ts +8 -0
  2. package/dist/auth/index.d.ts.map +1 -1
  3. package/dist/auth/index.js +53 -13
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/config.d.ts +24 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +12 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +19 -2
  11. package/dist/context.js.map +1 -1
  12. package/dist/generic/ResolverBase.d.ts +18 -3
  13. package/dist/generic/ResolverBase.d.ts.map +1 -1
  14. package/dist/generic/ResolverBase.js +46 -16
  15. package/dist/generic/ResolverBase.js.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  20. package/dist/resolvers/ReportResolver.js +2 -1
  21. package/dist/resolvers/ReportResolver.js.map +1 -1
  22. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  23. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  24. package/dist/rest/OAuthCallbackHandler.js +129 -43
  25. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  26. package/package.json +89 -89
  27. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  28. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  29. package/src/__tests__/ResolverBase.frozenRecordMapping.test.ts +135 -0
  30. package/src/__tests__/ResolverBase.transportMapping.test.ts +290 -0
  31. package/src/__tests__/newUsers.test.ts +729 -0
  32. package/src/auth/index.ts +56 -15
  33. package/src/config.ts +12 -0
  34. package/src/context.ts +19 -2
  35. package/src/generic/ResolverBase.ts +46 -16
  36. package/src/index.ts +3 -1
  37. package/src/resolvers/ReportResolver.ts +2 -1
  38. package/src/resolvers/__tests__/ReportResolver.test.ts +232 -0
  39. package/src/rest/OAuthCallbackHandler.ts +149 -41
@@ -16,7 +16,10 @@ interface PageRenderer {
16
16
  }
17
17
 
18
18
  function makeRenderer(): PageRenderer {
19
- const handler = new OAuthCallbackHandler({ publicUrl: 'https://example.test' });
19
+ const handler = new OAuthCallbackHandler({
20
+ publicUrl: 'https://example.test',
21
+ allowedFrontendOrigins: ['*'],
22
+ });
20
23
  return handler as unknown as PageRenderer;
21
24
  }
22
25
 
@@ -0,0 +1,135 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect } from 'vitest';
5
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+
8
+ /**
9
+ * `MapFieldNamesToCodeNames` must not write into the rows it is handed
10
+ * (PR #3425 review, finding M2).
11
+ *
12
+ * This is the SINGLE-RECORD half of the transport rename. The RunView half was fixed to
13
+ * copy-then-map, but this one still renamed `__mj_*` → `_mj__*` by writing onto its argument —
14
+ * and its arguments are frequently rows straight out of `findBy`/`RunView`, i.e. the server
15
+ * cache's own objects.
16
+ *
17
+ * On this line the corruption is SILENT: the rename rewrites the cached row's keys in place and
18
+ * later readers are served transport-shaped rows that `BaseEntity.SetMany` rejects. Freezing the
19
+ * fixtures below is how that silent write is turned into a visible failure — the rows are not
20
+ * frozen at runtime here. (Under 6.x freeze-on-write the same bug surfaces directly, as
21
+ * "Cannot add property _mj__CreatedAt, object is not extensible".)
22
+ *
23
+ * It reaches every call, because `MJ: Users` has caching enabled. `UserByID` and
24
+ * `UserByEmployeeID` share the code path, and so does every CodeGen-generated single-record
25
+ * resolver in `generated.ts` (`MapFieldNamesToCodeNames(entity, rows[0], ...)`), which is why
26
+ * the fix belongs in the shared helper rather than at the call sites.
27
+ */
28
+
29
+ /** Exposes the two protected mappers and supplies metadata without a live provider. */
30
+ class MappingProbe extends ResolverBase {
31
+ public MapOne(entityName: string, dataObject: unknown, provider: IMetadataProvider): Promise<unknown> {
32
+ return this.MapFieldNamesToCodeNames(entityName, dataObject, undefined, provider);
33
+ }
34
+ public MapMany(entityName: string, rows: unknown[], provider: IMetadataProvider, contextUser?: UserInfo): Promise<unknown[]> {
35
+ return this.ArrayMapFieldNamesToCodeNames(entityName, rows as Record<string, unknown>[], contextUser, provider);
36
+ }
37
+ }
38
+
39
+ const ENTITY_NAME = 'MJ: Users';
40
+
41
+ /**
42
+ * Minimal metadata: the field set drives the rename, `EncryptedFields` empty so the
43
+ * EncryptionEngine is never consulted.
44
+ */
45
+ function fakeProvider(): IMetadataProvider {
46
+ return {
47
+ EntityByName: (name: string) =>
48
+ name === ENTITY_NAME
49
+ ? {
50
+ Name: ENTITY_NAME,
51
+ Fields: [
52
+ { Name: 'ID', CodeName: 'ID' },
53
+ { Name: 'Name', CodeName: 'Name' },
54
+ { Name: '__mj_CreatedAt', CodeName: '__mj_CreatedAt' },
55
+ { Name: '__mj_UpdatedAt', CodeName: '__mj_UpdatedAt' },
56
+ ],
57
+ EncryptedFields: [],
58
+ }
59
+ : undefined,
60
+ } as unknown as IMetadataProvider;
61
+ }
62
+
63
+ /** A row as the cache hands it out: deep-frozen. */
64
+ function frozenCachedRow(): Record<string, unknown> {
65
+ return Object.freeze({
66
+ ID: 'u-1',
67
+ Name: 'Ada',
68
+ __mj_CreatedAt: 'T0',
69
+ __mj_UpdatedAt: 'T1',
70
+ }) as Record<string, unknown>;
71
+ }
72
+
73
+ describe('MapFieldNamesToCodeNames on frozen cache rows', () => {
74
+ it('maps a FROZEN row to transport keys instead of throwing (the live UserByEmail 500)', async () => {
75
+ const row = frozenCachedRow();
76
+
77
+ const mapped = (await new MappingProbe().MapOne(ENTITY_NAME, row, fakeProvider())) as Record<string, unknown>;
78
+
79
+ expect(mapped._mj__CreatedAt).toBe('T0');
80
+ expect(mapped._mj__UpdatedAt).toBe('T1');
81
+ expect(mapped.ID).toBe('u-1');
82
+ expect(mapped.Name).toBe('Ada');
83
+ // The transport aliases replace the originals in the OUTGOING shape.
84
+ expect(mapped.__mj_CreatedAt).toBeUndefined();
85
+ });
86
+
87
+ it('returns a copy — the caller\'s row keeps its entity field names', async () => {
88
+ // Unfrozen input, so a regression to in-place mapping would silently pass the frozen
89
+ // test above only if it also stopped mapping. This one pins non-mutation directly.
90
+ const row: Record<string, unknown> = { ID: 'u-1', Name: 'Ada', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
91
+
92
+ const mapped = (await new MappingProbe().MapOne(ENTITY_NAME, row, fakeProvider())) as Record<string, unknown>;
93
+
94
+ expect(row.__mj_CreatedAt).toBe('T0');
95
+ expect(row._mj__CreatedAt).toBeUndefined();
96
+ expect(mapped).not.toBe(row);
97
+ });
98
+
99
+ it('still returns null for empty/absent input (contract unchanged)', async () => {
100
+ const probe = new MappingProbe();
101
+ expect(await probe.MapOne(ENTITY_NAME, null, fakeProvider())).toBeNull();
102
+ expect(await probe.MapOne(ENTITY_NAME, {}, fakeProvider())).toBeNull();
103
+ });
104
+
105
+ it('leaves rows with no __mj_ fields structurally intact', async () => {
106
+ const row = Object.freeze({ ID: 'u-1', Name: 'Ada' }) as Record<string, unknown>;
107
+
108
+ const mapped = (await new MappingProbe().MapOne(ENTITY_NAME, row, fakeProvider())) as Record<string, unknown>;
109
+
110
+ expect(mapped).toEqual({ ID: 'u-1', Name: 'Ada' });
111
+ });
112
+ });
113
+
114
+ describe('ArrayMapFieldNamesToCodeNames on frozen cache rows', () => {
115
+ it('maps a frozen ARRAY of frozen rows without mutating either', async () => {
116
+ // The array is frozen as well as the rows: returning the caller's array (rather than a
117
+ // new one) is its own hazard, since `results.sort()`/`.push()` downstream would then be
118
+ // mutating cache-owned state. Freezing both is what makes that failure visible here.
119
+ const rows = Object.freeze([frozenCachedRow(), frozenCachedRow()]) as unknown as Record<string, unknown>[];
120
+
121
+ const mapped = (await new MappingProbe().MapMany(ENTITY_NAME, rows, fakeProvider())) as Record<string, unknown>[];
122
+
123
+ expect(mapped).toHaveLength(2);
124
+ expect(mapped[0]._mj__CreatedAt).toBe('T0');
125
+ expect(mapped[1]._mj__UpdatedAt).toBe('T1');
126
+ // Inputs untouched.
127
+ expect(rows[0].__mj_CreatedAt).toBe('T0');
128
+ expect(rows[0]._mj__CreatedAt).toBeUndefined();
129
+ expect(mapped[0]).not.toBe(rows[0]);
130
+ });
131
+
132
+ it('passes an empty array straight through', async () => {
133
+ expect(await new MappingProbe().MapMany(ENTITY_NAME, [], fakeProvider())).toEqual([]);
134
+ });
135
+ });
@@ -0,0 +1,290 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect } from 'vitest';
5
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { MJUserViewEntityExtended } from '@memberjunction/core-entities';
7
+ import { FieldMapper } from '@memberjunction/graphql-dataprovider';
8
+ import { ResolverBase } from '../generic/ResolverBase.js';
9
+ import type { UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * Regression guard for the server-cache corruption behind
13
+ * "Field _mj__CreatedAt does not exist on MJ: Template Categories".
14
+ *
15
+ * GraphQL reserves the `__` prefix, so MJ renames `__mj_*` columns to the
16
+ * transport alias `_mj__*` on the way out. `ResolverBase` used to apply that
17
+ * rename IN PLACE over `result.Results` — but those are the data provider's own
18
+ * row objects, and the server's cache holds them BY REFERENCE. Preparing one
19
+ * GraphQL response therefore rewrote the keys inside the live cache, and every
20
+ * subsequent read served from it handed the client transport-shaped rows that
21
+ * `BaseEntity.SetMany` rejects. Process-wide cache ⇒ one response poisoned every
22
+ * later request, across all workers.
23
+ *
24
+ * The fix is copy-then-map. The first block exercises the REAL resolver path (so a
25
+ * revert to in-place mapping fails here); the second pins the two `FieldMapper`
26
+ * invariants that make the fix correct.
27
+ *
28
+ * The third block runs the same path against FROZEN input. On this line nothing freezes
29
+ * cache rows, so `Object.freeze` here is a test instrument, not a simulation of runtime:
30
+ * it is how "never writes to its argument" is asserted directly rather than inferred. It
31
+ * also pre-stages the 6.x freeze-on-write behaviour, under which in-place mapping stops
32
+ * being silent corruption and becomes an outright `TypeError`.
33
+ */
34
+
35
+ /** A cache-held row, shaped the way the provider hands them back. */
36
+ type CachedRow = Record<string, unknown>;
37
+
38
+ /**
39
+ * Reaches the protected `RunViewGenericInternal` and records what
40
+ * `ArrayFilterEncryptedFieldsForAPI` was handed — the second in-place mutator that
41
+ * must also see copies, and stubbed here so the assertion needs no live Metadata.
42
+ */
43
+ class Probe extends ResolverBase {
44
+ public filteredRows: Record<string, unknown>[] | null = null;
45
+
46
+ protected override async ArrayFilterEncryptedFieldsForAPI(
47
+ _entityName: string,
48
+ dataObjectArray: Record<string, unknown>[]
49
+ ): Promise<Record<string, unknown>[]> {
50
+ this.filteredRows = dataObjectArray;
51
+ return dataObjectArray;
52
+ }
53
+
54
+ public Run(provider: DatabaseProviderBase, viewInfo: MJUserViewEntityExtended, userPayload: UserPayload) {
55
+ return this.RunViewGenericInternal(
56
+ provider,
57
+ viewInfo,
58
+ '', // extraFilter
59
+ '', // orderBy
60
+ '', // userSearchString
61
+ undefined, // excludeUserViewRunID
62
+ undefined, // overrideExcludeFilter
63
+ undefined, // saveViewResults
64
+ undefined, // fields
65
+ undefined, // ignoreMaxRows
66
+ undefined, // excludeDataFromAllPriorViewRuns
67
+ undefined, // forceAuditLog
68
+ undefined, // auditLogDescription
69
+ 'simple', // resultType
70
+ userPayload,
71
+ undefined, // maxRows
72
+ undefined // startRow
73
+ );
74
+ }
75
+ }
76
+
77
+ const ENTITY_NAME = 'MJ: Template Categories';
78
+
79
+ /** Minimal provider: the entity lookup + the RunView the resolver awaits. */
80
+ function fakeProvider(rows: CachedRow[]): DatabaseProviderBase {
81
+ return {
82
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
83
+ RunView: async (_params: RunViewParams): Promise<RunViewResult> =>
84
+ ({ Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult),
85
+ } as unknown as DatabaseProviderBase;
86
+ }
87
+
88
+ const fakeViewInfo = () =>
89
+ ({ ID: 'view-1', Name: 'Test View', Entity: ENTITY_NAME } as unknown as MJUserViewEntityExtended);
90
+
91
+ /** `userRecord` short-circuits the UserCache lookup; no apiKeyHash skips the scope check. */
92
+ const fakePayload = () =>
93
+ ({ email: 'tester@example.com', userRecord: { Email: 'tester@example.com' } as UserInfo } as UserPayload);
94
+
95
+ describe('ResolverBase.RunViewGenericInternal — cache safety', () => {
96
+ it('leaves the provider\'s (cache-held) rows untouched while returning transport keys', async () => {
97
+ const cachedRow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
98
+ const probe = new Probe();
99
+
100
+ const result = await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
101
+
102
+ // The pre-fix code renamed these keys in place — this is the assertion that fails on a revert.
103
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
104
+ expect(cachedRow.__mj_UpdatedAt).toBe('T1');
105
+ expect(cachedRow._mj__CreatedAt).toBeUndefined();
106
+
107
+ // The outgoing rows carry the GraphQL-legal aliases, and are NOT the cached objects.
108
+ const wire = result?.Results as Record<string, unknown>[];
109
+ expect(wire[0]._mj__CreatedAt).toBe('T0');
110
+ expect(wire[0]).not.toBe(cachedRow);
111
+ });
112
+
113
+ it('hands the encrypted-field filter the copies, not the cached rows', async () => {
114
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
115
+ const probe = new Probe();
116
+
117
+ await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
118
+
119
+ expect(probe.filteredRows).not.toBeNull();
120
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
121
+ });
122
+ });
123
+
124
+ describe('FieldMapper transport mapping — the invariants the fix rests on', () => {
125
+ it('MapFields MUTATES its argument (the hazard the resolver must not expose the cache to)', () => {
126
+ // Documents WHY the resolver must copy. If this ever becomes non-mutating,
127
+ // the copy in ResolverBase is redundant rather than load-bearing — and this
128
+ // test failing is the signal to revisit it.
129
+ const row: Record<string, unknown> = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' };
130
+
131
+ new FieldMapper().MapFields(row);
132
+
133
+ expect(row.__mj_CreatedAt).toBeUndefined();
134
+ expect(row._mj__CreatedAt).toBe('T0');
135
+ });
136
+
137
+ it('post-map mutation of the copy cannot reach the cached row', () => {
138
+ // ArrayFilterEncryptedFieldsForAPI redacts in place after mapping; on the
139
+ // pre-fix code that stripped secrets out of the CACHED row too.
140
+ const mapper = new FieldMapper();
141
+ const cachedRow: Record<string, unknown> = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
142
+
143
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
144
+ wireRow.Secret = null; // stand-in for the encrypted-field filter
145
+
146
+ expect(cachedRow.Secret).toBe('plaintext');
147
+ expect(wireRow.Secret).toBeNull();
148
+ });
149
+
150
+ it('round-trips a mapped copy back to entity field names', () => {
151
+ // The client's ConvertBackToMJFields must undo exactly what the server did.
152
+ const mapper = new FieldMapper();
153
+ const original: Record<string, unknown> = { ID: 'a1', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
154
+
155
+ const wire = mapper.MapFields({ ...original })!;
156
+ const back = mapper.ReverseMapFields({ ...wire });
157
+
158
+ expect(back).toEqual(original);
159
+ });
160
+
161
+ it('leaves rows without __mj_ fields structurally unchanged', () => {
162
+ const mapper = new FieldMapper();
163
+ const row = { ID: 'a1', Name: 'Cat' };
164
+
165
+ expect(mapper.MapFields({ ...row })).toEqual(row);
166
+ });
167
+ });
168
+
169
+ describe('transport mapping against FROZEN cache rows', () => {
170
+ // Freezing is the assertion mechanism, not a reproduction of runtime state: on this line
171
+ // the cache hands out live, unfrozen references. A frozen input makes "the mapper wrote to
172
+ // its argument" fail loudly here instead of passing silently and corrupting the cache in
173
+ // production. It is also the shape 6.x's freeze-on-write will actually hand out.
174
+ it('copy-then-map works on a frozen row', () => {
175
+ const mapper = new FieldMapper();
176
+ const cachedRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
177
+
178
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
179
+
180
+ expect(wireRow._mj__CreatedAt).toBe('T0');
181
+ expect(wireRow.__mj_CreatedAt).toBeUndefined();
182
+ // The copy is mutable, so the encrypted-field filter can still redact in place.
183
+ expect(Object.isFrozen(wireRow)).toBe(false);
184
+ // And the frozen cached row is untouched.
185
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
186
+ });
187
+
188
+ it('in-place mapping of a frozen row THROWS — silent corruption is now impossible', () => {
189
+ // This is the safety net the freeze adds on top of the copy: if a future refactor
190
+ // reverts to in-place mapping, it fails loudly at the offending line instead of
191
+ // quietly rewriting process-wide state.
192
+ const mapper = new FieldMapper();
193
+ const cachedRow = Object.freeze({ ID: 'a1', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
194
+
195
+ expect(() => mapper.MapFields(cachedRow)).toThrow(TypeError);
196
+ });
197
+ });
198
+
199
+ /**
200
+ * The BATCH path (`RunViewsGenericInternal`), which the single-view tests above do not reach
201
+ * (PR #3425 review, finding M8).
202
+ *
203
+ * This is the path multi-view clients actually take — MJExplorer batches its view loads — so
204
+ * leaving it uncovered meant the original corruption bug could be reintroduced on the more
205
+ * heavily used of the two legs with CI still green.
206
+ */
207
+ class BatchProbe extends ResolverBase {
208
+ public filteredRows: Record<string, unknown>[] | null = null;
209
+
210
+ protected override async ArrayFilterEncryptedFieldsForAPI(
211
+ _entityName: string,
212
+ dataObjectArray: Record<string, unknown>[]
213
+ ): Promise<Record<string, unknown>[]> {
214
+ this.filteredRows = dataObjectArray;
215
+ return dataObjectArray;
216
+ }
217
+
218
+ public RunBatch(provider: DatabaseProviderBase, viewInfos: MJUserViewEntityExtended[], userPayload: UserPayload) {
219
+ return this.RunViewsGenericInternal(
220
+ viewInfos.map(viewInfo => ({ provider, viewInfo, userPayload, resultType: 'simple' })) as never
221
+ );
222
+ }
223
+ }
224
+
225
+ /** Batch provider: `RunViews` returns one result per param, in order. */
226
+ function fakeBatchProvider(rowsPerView: CachedRow[][]): DatabaseProviderBase {
227
+ return {
228
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
229
+ EntityByName: (name: string) => (name === ENTITY_NAME ? { Name: ENTITY_NAME } : undefined),
230
+ RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> =>
231
+ params.map((_p, i) => {
232
+ const rows = rowsPerView[i] ?? [];
233
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
234
+ }),
235
+ } as unknown as DatabaseProviderBase;
236
+ }
237
+
238
+ describe('ResolverBase.RunViewsGenericInternal (batch) — cache safety', () => {
239
+ it('leaves every view\'s cache-held rows untouched while returning transport keys', async () => {
240
+ const viewARow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
241
+ const viewBRow: CachedRow = { ID: 'b1', Name: 'Dog', __mj_CreatedAt: 'T2', __mj_UpdatedAt: 'T3' };
242
+ const probe = new BatchProbe();
243
+
244
+ const results = await probe.RunBatch(
245
+ fakeBatchProvider([[viewARow], [viewBRow]]),
246
+ [fakeViewInfo(), fakeViewInfo()],
247
+ fakePayload()
248
+ );
249
+
250
+ // Both source rows keep entity field names — the assertion that fails on a revert
251
+ // to in-place mapping in the batch loop.
252
+ expect(viewARow.__mj_CreatedAt).toBe('T0');
253
+ expect(viewARow._mj__CreatedAt).toBeUndefined();
254
+ expect(viewBRow.__mj_CreatedAt).toBe('T2');
255
+ expect(viewBRow._mj__CreatedAt).toBeUndefined();
256
+
257
+ // ...and both outgoing views carry the aliases, on objects that are not the cached ones.
258
+ const wireA = results[0].Results as Record<string, unknown>[];
259
+ const wireB = results[1].Results as Record<string, unknown>[];
260
+ expect(wireA[0]._mj__CreatedAt).toBe('T0');
261
+ expect(wireB[0]._mj__CreatedAt).toBe('T2');
262
+ expect(wireA[0]).not.toBe(viewARow);
263
+ expect(wireB[0]).not.toBe(viewBRow);
264
+ });
265
+
266
+ it('maps FROZEN cache rows rather than throwing on them', async () => {
267
+ // The shape the cache actually hands out on a hit.
268
+ const frozenRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as CachedRow;
269
+ const probe = new BatchProbe();
270
+
271
+ const results = await probe.RunBatch(
272
+ fakeBatchProvider([[frozenRow]]),
273
+ [fakeViewInfo()],
274
+ fakePayload()
275
+ );
276
+
277
+ expect((results[0].Results as Record<string, unknown>[])[0]._mj__CreatedAt).toBe('T0');
278
+ expect(frozenRow.__mj_CreatedAt).toBe('T0');
279
+ });
280
+
281
+ it('hands the encrypted-field filter copies, not the cached rows', async () => {
282
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
283
+ const probe = new BatchProbe();
284
+
285
+ await probe.RunBatch(fakeBatchProvider([[cachedRow]]), [fakeViewInfo()], fakePayload());
286
+
287
+ expect(probe.filteredRows).not.toBeNull();
288
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
289
+ });
290
+ });