@memberjunction/server 6.1.5 → 6.1.6

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 (189) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/exampleNewUserSubClass.js +2 -1
  19. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  22. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  23. package/dist/auth/newUsers.d.ts.map +1 -1
  24. package/dist/auth/newUsers.js +15 -11
  25. package/dist/auth/newUsers.js.map +1 -1
  26. package/dist/context.d.ts +6 -0
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/context.js +13 -11
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  31. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  32. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  33. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  34. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  35. package/dist/generic/CacheInvalidationResolver.js +59 -19
  36. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  37. package/dist/generic/ResolverBase.d.ts +95 -11
  38. package/dist/generic/ResolverBase.d.ts.map +1 -1
  39. package/dist/generic/ResolverBase.js +186 -76
  40. package/dist/generic/ResolverBase.js.map +1 -1
  41. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  42. package/dist/generic/RunViewResolver.js +12 -0
  43. package/dist/generic/RunViewResolver.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/isolatedProvider.d.ts +20 -0
  49. package/dist/isolatedProvider.d.ts.map +1 -0
  50. package/dist/isolatedProvider.js +22 -0
  51. package/dist/isolatedProvider.js.map +1 -0
  52. package/dist/multiTenancy/index.d.ts.map +1 -1
  53. package/dist/multiTenancy/index.js +22 -2
  54. package/dist/multiTenancy/index.js.map +1 -1
  55. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  56. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  57. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  58. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  59. package/dist/resolvers/ActionResolver.d.ts +31 -5
  60. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/ActionResolver.js +75 -14
  62. package/dist/resolvers/ActionResolver.js.map +1 -1
  63. package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
  64. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  65. package/dist/resolvers/AdhocQueryResolver.js +46 -0
  66. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  67. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  68. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  69. package/dist/resolvers/DatasetResolver.js +57 -8
  70. package/dist/resolvers/DatasetResolver.js.map +1 -1
  71. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  72. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  73. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  74. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  75. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  76. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  77. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  78. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  79. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  80. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  81. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  82. package/dist/resolvers/GetDataResolver.js +13 -1
  83. package/dist/resolvers/GetDataResolver.js.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  85. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  86. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  89. package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
  90. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  91. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  92. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  93. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  94. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  95. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RunTemplateResolver.js +12 -11
  97. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  100. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  101. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  102. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  103. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  104. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  105. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SyncDataResolver.js +6 -2
  107. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  108. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  109. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  110. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  111. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  112. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  113. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  114. package/dist/rest/ViewOperationsHandler.js +29 -17
  115. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  116. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  117. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  118. package/dist/services/TaskGraphActionRunner.js +27 -1
  119. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  120. package/package.json +92 -92
  121. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  122. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
  123. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  124. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  125. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  126. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  127. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  128. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  129. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  130. package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
  131. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  132. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  133. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  134. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  135. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  136. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  137. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  138. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  139. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  140. package/src/__tests__/SessionManager.test.ts +94 -17
  141. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  142. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  143. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  144. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  145. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  146. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  147. package/src/__tests__/actionAuthorization.test.ts +130 -0
  148. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  149. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  150. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  151. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  152. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  153. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  154. package/src/__tests__/multiTenancy.test.ts +1 -1
  155. package/src/__tests__/newUsers.test.ts +62 -5
  156. package/src/agentSessions/SessionManager.ts +23 -25
  157. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  158. package/src/agentSessions/index.ts +1 -0
  159. package/src/auth/actionAuthorization.ts +119 -0
  160. package/src/auth/exampleNewUserSubClass.ts +2 -2
  161. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  162. package/src/auth/newUsers.ts +15 -11
  163. package/src/context.ts +14 -10
  164. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  165. package/src/generic/CacheInvalidationResolver.ts +74 -19
  166. package/src/generic/ResolverBase.ts +207 -80
  167. package/src/generic/RunViewResolver.ts +17 -0
  168. package/src/index.ts +9 -1
  169. package/src/isolatedProvider.ts +22 -0
  170. package/src/multiTenancy/index.ts +24 -2
  171. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  172. package/src/resolvers/ActionResolver.ts +84 -21
  173. package/src/resolvers/AdhocQueryResolver.ts +49 -1
  174. package/src/resolvers/DatasetResolver.ts +70 -10
  175. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  176. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  177. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  178. package/src/resolvers/GetDataResolver.ts +14 -1
  179. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  180. package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
  181. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  182. package/src/resolvers/RunTemplateResolver.ts +13 -12
  183. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  184. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  185. package/src/resolvers/SyncDataResolver.ts +6 -2
  186. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  187. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  188. package/src/rest/ViewOperationsHandler.ts +37 -18
  189. package/src/services/TaskGraphActionRunner.ts +29 -1
@@ -0,0 +1,127 @@
1
+ // ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect } from 'vitest';
4
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
5
+ import type { PubSubEngine } from 'type-graphql';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import type { RunDynamicViewInput } from '../generic/RunViewResolver.js';
8
+ import type { UserPayload } from '../types.js';
9
+
10
+ /**
11
+ * `RunDynamicView` is the GraphQL entry point for a client RunView over one entity. A subquery in
12
+ * its ExtraFilter reads an entity's base view directly, so RunView applies neither that entity's
13
+ * row-level security nor its denied-field projection to the subquery. These tests pin that the
14
+ * boundary screen refuses such a subquery when the caller's read of that entity is narrowed (the
15
+ * rule the ad-hoc SQL screen applies) and still passes it when the read is not. Inputs are harmless.
16
+ */
17
+
18
+ /** How the caller's read of an entity is narrowed: a row filter, denied fields, or neither. */
19
+ interface ReadScope {
20
+ RowFilter?: string;
21
+ DeniedFields?: string[];
22
+ }
23
+
24
+ /** What the resolver handed to RunView; null when the request never got there. */
25
+ type Captured = { params: RunViewParams | null };
26
+
27
+ const USER = { ID: 'U1', Email: 'reader@example.com' } as UserInfo;
28
+ const OWN_ROWS = "(OwnerID = 'U1')";
29
+ const ORDERS_SUBQUERY = `ID IN (SELECT AccountID FROM [crm].[vwOrders] WHERE [Total] > 100)`;
30
+
31
+ /** Provider double: two entities the caller can read, each under `scopes[Name]`, and a RunView that records its params. */
32
+ function fakeProvider(captured: Captured, scopes: Record<string, ReadScope> = {}): DatabaseProviderBase {
33
+ const entity = (id: string, name: string, baseView: string) => ({
34
+ ID: id,
35
+ Name: name,
36
+ SchemaName: 'crm',
37
+ BaseView: baseView,
38
+ GetUserPermisions: () => ({ CanRead: true }),
39
+ GetEffectiveRowFilterWhereClause: () => scopes[name]?.RowFilter ?? '',
40
+ GetDeniedReadFields: () => new Set(scopes[name]?.DeniedFields ?? []),
41
+ });
42
+ return {
43
+ Entities: [entity('E1', 'Accounts', 'vwAccounts'), entity('E2', 'Orders', 'vwOrders')],
44
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
45
+ captured.params = params;
46
+ return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as unknown as RunViewResult;
47
+ },
48
+ } as unknown as DatabaseProviderBase;
49
+ }
50
+
51
+ /** Runs `RunDynamicViewGeneric` over Accounts as USER. */
52
+ class Probe extends ResolverBase {
53
+ public RunDynamic(input: Partial<RunDynamicViewInput>, provider: DatabaseProviderBase) {
54
+ const payload = { email: USER.Email, userRecord: USER } as UserPayload;
55
+ const view = { EntityName: 'Accounts', ...input } as RunDynamicViewInput;
56
+ return this.RunDynamicViewGeneric(view, provider, payload, undefined as unknown as PubSubEngine);
57
+ }
58
+ }
59
+
60
+ describe('RunDynamicViewGeneric — a client subquery reads an entity only when the caller may read it unscoped', () => {
61
+ it('refuses a subquery over an entity that is row-level-security filtered for the caller, before RunView', async () => {
62
+ const captured: Captured = { params: null };
63
+
64
+ await expect(
65
+ new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
66
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
67
+ expect(captured.params).toBeNull();
68
+ });
69
+
70
+ it('refuses a subquery over an entity with a field the caller may not read, before RunView', async () => {
71
+ const captured: Captured = { params: null };
72
+
73
+ await expect(
74
+ new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured, { Orders: { DeniedFields: ['total'] } })),
75
+ ).rejects.toThrow(/'Orders' has fields you are not permitted to read/);
76
+ expect(captured.params).toBeNull();
77
+ });
78
+
79
+ it('passes the same subquery through when the caller reads that entity unscoped', async () => {
80
+ const captured: Captured = { params: null };
81
+
82
+ await new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured));
83
+
84
+ expect(captured.params?.ExtraFilter).toBe(ORDERS_SUBQUERY);
85
+ });
86
+
87
+ it("refuses a subquery over the queried entity's own base view when the caller's read of it is row-filtered", async () => {
88
+ const captured: Captured = { params: null };
89
+ const ownView = `ID IN (SELECT ID FROM [crm].[vwAccounts] WHERE [Name] LIKE 'A%')`;
90
+
91
+ await expect(
92
+ new Probe().RunDynamic({ ExtraFilter: ownView }, fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } })),
93
+ ).rejects.toThrow(/'Accounts' is row-level-security filtered for you/);
94
+ expect(captured.params).toBeNull();
95
+ });
96
+
97
+ it('refuses a CASE-WHEN subquery over a row-filtered entity, before RunView', async () => {
98
+ const captured: Captured = { params: null };
99
+ const caseWhen = `CASE WHEN (SELECT COUNT(*) FROM [crm].[vwOrders] WHERE [Total] > 100) > 0 THEN 1 ELSE 0 END = 1`;
100
+
101
+ await expect(
102
+ new Probe().RunDynamic({ ExtraFilter: caseWhen }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
103
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
104
+ expect(captured.params).toBeNull();
105
+ });
106
+
107
+ it("refuses a subquery over a row-filtered entity in a window's ORDER BY, before RunView", async () => {
108
+ const captured: Captured = { params: null };
109
+ const window = `ID IN (SELECT ID FROM (SELECT ID, ROW_NUMBER() OVER (ORDER BY (SELECT COUNT(*) FROM [crm].[vwOrders])) AS rn FROM [crm].[vwAccounts]) x)`;
110
+
111
+ await expect(
112
+ new Probe().RunDynamic({ ExtraFilter: window }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
113
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
114
+ expect(captured.params).toBeNull();
115
+ });
116
+
117
+ it('still passes an ordinary filter and sort on an entity the caller reads under a row filter', async () => {
118
+ const captured: Captured = { params: null };
119
+
120
+ await new Probe().RunDynamic(
121
+ { ExtraFilter: `[Name] = 'Acme'`, OrderBy: '[Name] DESC' },
122
+ fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } }),
123
+ );
124
+
125
+ expect(captured.params?.ExtraFilter).toBe(`[Name] = 'Acme'`);
126
+ });
127
+ });
@@ -33,7 +33,16 @@ type Captured = { params: RunViewParams | null };
33
33
  * its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
34
34
  * Number and Boolean TS types).
35
35
  */
36
- function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
36
+ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = [], unreadableEntities: string[] = []): DatabaseProviderBase {
37
+ const withPerms = (e: Record<string, unknown>) => ({
38
+ ...e,
39
+ // Real EntityInfo aggregates per-user permissions; the boundary screen consults CanRead,
40
+ // the row filter and the denied fields for every subquery target, so the mock answers
41
+ // them (an unscoped read).
42
+ GetUserPermisions: () => ({ CanRead: !unreadableEntities.includes(e.Name as string) }),
43
+ GetEffectiveRowFilterWhereClause: () => '',
44
+ GetDeniedReadFields: () => new Set<string>(),
45
+ });
37
46
  return {
38
47
  Entities: [
39
48
  {
@@ -67,7 +76,7 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
67
76
  { Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
68
77
  { Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
69
78
  { Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
70
- ],
79
+ ].map(withPerms),
71
80
  RunView: async (params: RunViewParams): Promise<RunViewResult> => {
72
81
  captured.params = params;
73
82
  return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
@@ -246,6 +255,19 @@ describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
246
255
  expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
247
256
  });
248
257
 
258
+ it('RunDynamicViewGeneric rejects a subquery into an entity the caller cannot read', async () => {
259
+ const captured: Captured = { params: null };
260
+ const input = {
261
+ EntityName: ENTITY_NAME,
262
+ ExtraFilter: `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments])`,
263
+ } as RunDynamicViewInput;
264
+
265
+ await expect(
266
+ new Probe().RunDynamic(input, fakeProvider(captured, [], ['MJ_BizApps_Tasks: Task Assignments']))
267
+ ).rejects.toThrow(/read permission/);
268
+ expect(captured.params).toBeNull();
269
+ });
270
+
249
271
  it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
250
272
  const captured: Captured = { params: null };
251
273
  const input = {
@@ -0,0 +1,412 @@
1
+ /**
2
+ * IS-A PROMOTION over the wire — the server half.
3
+ *
4
+ * Promotion = an EXISTING parent row gains a subtype ("this Animal is now also a Dog"). Over
5
+ * GraphQL it arrives as the child's CREATE mutation carrying the EXISTING parent's primary key,
6
+ * because in IS-A the shared key IS the relationship — the child has no key of its own to mint.
7
+ *
8
+ * The defect: `ResolverBase.CreateRecord` did `GetEntityObject` + `NewRecord()` + `SetMany(input)`.
9
+ * `NewRecord()` recurses into the parent chain and flags the PARENT as new too; `SetMany` then only
10
+ * overwrites the key. The parent therefore saved as a CREATE — a second copy of a row that already
11
+ * exists — instead of the child being attached to the existing row.
12
+ *
13
+ * These tests drive the REAL `CreateRecord` with REAL `BaseEntity` / `EntityInfo` objects over a
14
+ * fake provider that (a) constructs entities exactly as `ProviderBase.GetEntityObject` does
15
+ * (construct → Config → InitializeParentEntity → NewRecord), (b) holds one existing Animal row for
16
+ * `Load`, and (c) records every `Save` it receives — entity, create-vs-update, key — which is the
17
+ * observable the bug corrupts.
18
+ */
19
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
20
+ // Reflect.metadata polyfill at import time.
21
+ import 'reflect-metadata';
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23
+ import {
24
+ BaseEntity,
25
+ CompositeKey,
26
+ DatabaseProviderBase,
27
+ EntityInfo,
28
+ EntitySaveOptions,
29
+ FieldPermissionAccess,
30
+ IMetadataProvider,
31
+ Metadata,
32
+ UserInfo,
33
+ } from '@memberjunction/core';
34
+ import type { PubSubEngine } from 'type-graphql';
35
+ import { ResolverBase } from '../generic/ResolverBase.js';
36
+ import type { UserPayload } from '../types.js';
37
+
38
+ // ────────────────────────────────────────────────────────────────────────────
39
+ // Fixtures — a real two-level IS-A pair: Animals (root) ← Dogs (child), plus a standalone entity
40
+ // ────────────────────────────────────────────────────────────────────────────
41
+
42
+ const ROLE_ID = 'D1D2D3D4-0000-4000-8000-00000000R0LE';
43
+ const USER_ID = 'D1D2D3D4-0000-4000-8000-00000000USER';
44
+ const ANIMAL_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000001';
45
+ const DOG_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000002';
46
+ const TOY_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000003';
47
+ /** The Animal row that already exists in the database. */
48
+ const EXISTING_ANIMAL_ID = 'E1E2E3E4-0000-4000-8000-00000000AAAA';
49
+ /** A well-formed key that matches NO row. */
50
+ const MISSING_ANIMAL_ID = 'E1E2E3E4-0000-4000-8000-00000000FFFF';
51
+
52
+ interface FieldSpec {
53
+ EntityID: string;
54
+ Name: string;
55
+ Type: string;
56
+ Sequence: number;
57
+ IsPrimaryKey?: boolean;
58
+ AllowsNull?: boolean;
59
+ AllowUpdateAPI?: boolean;
60
+ IsVirtual?: boolean;
61
+ /** Field-level security rows (only read on an entity with EnableFieldLevelSecurity). */
62
+ EntityFieldPermissions?: Record<string, unknown>[];
63
+ }
64
+
65
+ function field(spec: FieldSpec): Record<string, unknown> {
66
+ return {
67
+ ID: `EF-${spec.EntityID}-${spec.Sequence}`,
68
+ EntityID: spec.EntityID,
69
+ Name: spec.Name,
70
+ Type: spec.Type,
71
+ Length: null,
72
+ IsPrimaryKey: spec.IsPrimaryKey ?? false,
73
+ IsSoftPrimaryKey: false,
74
+ IsSoftForeignKey: false,
75
+ AllowsNull: spec.AllowsNull ?? true,
76
+ AllowUpdateAPI: spec.AllowUpdateAPI ?? false,
77
+ DefaultValue: null,
78
+ AutoIncrement: false,
79
+ IsVirtual: spec.IsVirtual ?? false,
80
+ IsNameField: false,
81
+ ValueListType: 'None',
82
+ Sequence: spec.Sequence,
83
+ Status: 'Active',
84
+ EntityFieldValues: [],
85
+ EntityFieldPermissions: spec.EntityFieldPermissions ?? [],
86
+ };
87
+ }
88
+
89
+ /**
90
+ * One field-level permission row for the test role. On an FLS-enabled entity a field with NO rows
91
+ * is denied, so every non-key field of such an entity gets an explicit row.
92
+ */
93
+ function fieldPermission(fieldName: string, access: { read?: string; update?: string; create?: string }): Record<string, unknown> {
94
+ const sequence: Record<string, number> = { Name: 2, MicrochipNumber: 3 }; // matches field()'s `EF-<entity>-<sequence>` ids
95
+ return {
96
+ ID: `fperm-${fieldName}`,
97
+ EntityFieldID: `EF-${ANIMAL_ENTITY_ID}-${sequence[fieldName]}`,
98
+ RoleID: ROLE_ID,
99
+ ReadAccess: access.read ?? FieldPermissionAccess.Allow,
100
+ UpdateAccess: access.update ?? FieldPermissionAccess.Allow,
101
+ CreateAccess: access.create ?? FieldPermissionAccess.Allow,
102
+ };
103
+ }
104
+
105
+ function permission(entityID: string): Record<string, unknown> {
106
+ return { ID: `perm-${entityID}`, EntityID: entityID, RoleID: ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true };
107
+ }
108
+
109
+ function entityData(overrides: {
110
+ ID: string; Name: string; BaseTable: string; ParentID: string | null; EntityFields: Record<string, unknown>[];
111
+ EnableFieldLevelSecurity?: boolean; AllowMultipleSubtypes?: boolean;
112
+ }): Record<string, unknown> {
113
+ return {
114
+ ID: overrides.ID,
115
+ Name: overrides.Name,
116
+ BaseTable: overrides.BaseTable,
117
+ BaseView: `vw${overrides.BaseTable}s`,
118
+ SchemaName: 'Shelter',
119
+ VirtualEntity: false,
120
+ AllowCreateAPI: true,
121
+ AllowUpdateAPI: true,
122
+ AllowDeleteAPI: true,
123
+ IncludeInAPI: true,
124
+ AllowMultipleSubtypes: overrides.AllowMultipleSubtypes ?? false,
125
+ EnableFieldLevelSecurity: overrides.EnableFieldLevelSecurity ?? false,
126
+ ParentID: overrides.ParentID,
127
+ Status: 'Active',
128
+ EntityFields: overrides.EntityFields,
129
+ EntityPermissions: [permission(overrides.ID)],
130
+ EntityRelationships: [],
131
+ EntitySettings: [],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Fixture options. `nameLockedByFls`: Animals runs with field-level security ON and the test role
137
+ * may READ but neither UPDATE nor CREATE `Name` (every other field stays open). `overlapping`:
138
+ * Animals allows multiple subtypes.
139
+ */
140
+ function buildEntities(options: { nameLockedByFls?: boolean; overlapping?: boolean } = {}): EntityInfo[] {
141
+ const fls = options.nameLockedByFls === true;
142
+ const animals = entityData({
143
+ ID: ANIMAL_ENTITY_ID, Name: 'Animals', BaseTable: 'Animal', ParentID: null,
144
+ EnableFieldLevelSecurity: fls,
145
+ AllowMultipleSubtypes: options.overlapping === true,
146
+ EntityFields: [
147
+ field({ EntityID: ANIMAL_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
148
+ field({ EntityID: ANIMAL_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, Sequence: 2,
149
+ EntityFieldPermissions: fls ? [fieldPermission('Name', { update: FieldPermissionAccess.Deny, create: FieldPermissionAccess.Deny })] : [] }),
150
+ field({ EntityID: ANIMAL_ENTITY_ID, Name: 'MicrochipNumber', Type: 'nvarchar', AllowsNull: true, AllowUpdateAPI: true, Sequence: 3,
151
+ EntityFieldPermissions: fls ? [fieldPermission('MicrochipNumber', {})] : [] }),
152
+ ],
153
+ });
154
+ // Mirrors what CodeGen emits for an IS-A child: its OWN `ID` PK (shared with the parent), its
155
+ // own columns, and the parent's data fields as virtual, updatable mirrors.
156
+ const dogs = entityData({
157
+ ID: DOG_ENTITY_ID, Name: 'Dogs', BaseTable: 'Dog', ParentID: ANIMAL_ENTITY_ID,
158
+ EntityFields: [
159
+ field({ EntityID: DOG_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
160
+ field({ EntityID: DOG_ENTITY_ID, Name: 'IsHouseTrained', Type: 'bit', AllowsNull: true, AllowUpdateAPI: true, Sequence: 2 }),
161
+ field({ EntityID: DOG_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, IsVirtual: true, Sequence: 3 }),
162
+ field({ EntityID: DOG_ENTITY_ID, Name: 'MicrochipNumber', Type: 'nvarchar', AllowsNull: true, AllowUpdateAPI: true, IsVirtual: true, Sequence: 4 }),
163
+ ],
164
+ });
165
+ const toys = entityData({
166
+ ID: TOY_ENTITY_ID, Name: 'Toys', BaseTable: 'Toy', ParentID: null,
167
+ EntityFields: [
168
+ field({ EntityID: TOY_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
169
+ field({ EntityID: TOY_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, Sequence: 2 }),
170
+ ],
171
+ });
172
+ return [animals, dogs, toys].map(d => new EntityInfo(d));
173
+ }
174
+
175
+ function buildUser(): UserInfo {
176
+ return new UserInfo(null, {
177
+ ID: USER_ID, Name: 'Test User', Email: 'test.user@example.com',
178
+ FirstName: 'Test', LastName: 'User', IsActive: true,
179
+ UserRoles: [{ UserID: USER_ID, RoleID: ROLE_ID, RoleName: 'Admin' }],
180
+ });
181
+ }
182
+
183
+ // ────────────────────────────────────────────────────────────────────────────
184
+ // Fake provider — real entity construction, an in-memory row store, a Save log
185
+ // ────────────────────────────────────────────────────────────────────────────
186
+
187
+ /** Concrete BaseEntity (mirrors a generated entity class). */
188
+ class TestEntity extends BaseEntity {}
189
+
190
+ interface SaveRecord {
191
+ Entity: string;
192
+ Type: 'create' | 'update';
193
+ ID: unknown;
194
+ Values: Record<string, unknown>;
195
+ /** Fields field-level security told the provider to OMIT from an INSERT (CreateSuppressed). */
196
+ SuppressedOnCreate: string[];
197
+ }
198
+
199
+ class FakeIsaProvider {
200
+ public readonly Entities: EntityInfo[];
201
+ public readonly CurrentUser: UserInfo;
202
+ /** Rows the "database" holds, keyed by entity name then primary key. */
203
+ public rows: Record<string, Record<string, Record<string, unknown>>> = {};
204
+ /** Every Save the ORM handed us, in order — the observable the defect corrupts. */
205
+ public saves: SaveRecord[] = [];
206
+ /** Every Load the ORM asked for, in order. */
207
+ public loads: { Entity: string; ID: unknown }[] = [];
208
+
209
+ constructor(entities: EntityInfo[], user: UserInfo) {
210
+ this.Entities = entities;
211
+ this.CurrentUser = user;
212
+ }
213
+
214
+ public EntityByName(name: string): EntityInfo | undefined {
215
+ return this.Entities.find(e => e.Name.trim().toLowerCase() === name.trim().toLowerCase());
216
+ }
217
+
218
+ public EntityByID(id: string): EntityInfo | undefined {
219
+ return this.Entities.find(e => e.ID === id);
220
+ }
221
+
222
+ /** The same construction sequence as ProviderBase.GetEntityObject (minus the class factory). */
223
+ public async GetEntityObject(entityName: string, contextUser?: UserInfo): Promise<BaseEntity> {
224
+ const info = this.EntityByName(entityName);
225
+ if (!info) throw new Error(`Entity ${entityName} not found`);
226
+ const entity = new TestEntity(info, this as unknown as ConstructorParameters<typeof TestEntity>[1]);
227
+ await entity.Config(contextUser ?? this.CurrentUser);
228
+ await entity.InitializeParentEntity();
229
+ entity.NewRecord();
230
+ return entity;
231
+ }
232
+
233
+ public async Load(entity: BaseEntity, key: CompositeKey): Promise<Record<string, unknown> | null> {
234
+ const id = key.GetValueByFieldName('ID') as string;
235
+ this.loads.push({ Entity: entity.EntityInfo.Name, ID: id });
236
+ const row = this.rows[entity.EntityInfo.Name]?.[id];
237
+ return row ? { ...row } : null;
238
+ }
239
+
240
+ public async Save(entity: BaseEntity, _user: UserInfo, _options: EntitySaveOptions): Promise<Record<string, unknown>> {
241
+ this.saves.push({
242
+ Entity: entity.EntityInfo.Name,
243
+ Type: entity.IsSaved ? 'update' : 'create',
244
+ ID: entity.Get('ID'),
245
+ Values: entity.GetAll(),
246
+ SuppressedOnCreate: entity.Fields.filter(f => f.CreateSuppressed).map(f => f.Name),
247
+ });
248
+ return entity.GetAll();
249
+ }
250
+
251
+ public SetCachedRecordName(): void { /* cache is irrelevant here */ }
252
+ public GetCachedRecordName(): string | null { return null; }
253
+ }
254
+
255
+ /** Exposes the REAL protected CreateRecord; pub/sub wiring is out of scope for a unit test. */
256
+ class CreateProbe extends ResolverBase {
257
+ public Create(entityName: string, input: Record<string, unknown>, provider: FakeIsaProvider, payload: UserPayload): Promise<Record<string, unknown> | null> {
258
+ return this.CreateRecord(
259
+ entityName,
260
+ input,
261
+ provider as unknown as DatabaseProviderBase,
262
+ payload,
263
+ undefined as unknown as PubSubEngine
264
+ ) as Promise<Record<string, unknown> | null>;
265
+ }
266
+ protected override ListenForEntityMessages(): void { /* no pub/sub in a unit test */ }
267
+ }
268
+
269
+ describe('ResolverBase.CreateRecord — IS-A promotion attaches the new child to the EXISTING parent', () => {
270
+ let provider: FakeIsaProvider;
271
+ let payload: UserPayload;
272
+ let previousGlobalProvider: IMetadataProvider;
273
+
274
+ /** (Re)build the fake provider over a fixture variant and publish it as the global provider. */
275
+ function useFixture(options: { nameLockedByFls?: boolean; overlapping?: boolean } = {}): void {
276
+ const user = buildUser();
277
+ provider = new FakeIsaProvider(buildEntities(options), user);
278
+ provider.rows = {
279
+ Animals: {
280
+ [EXISTING_ANIMAL_ID]: { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit', MicrochipNumber: '985112000000001' },
281
+ },
282
+ Dogs: {},
283
+ };
284
+ Metadata.Provider = provider as unknown as IMetadataProvider;
285
+ payload = { email: user.Email, userRecord: user, sessionId: 'test-session' };
286
+ }
287
+
288
+ beforeEach(() => {
289
+ // EntityInfo resolves ParentEntityInfo / ChildEntities through the GLOBAL provider (a
290
+ // stateless info-class proxy), and MapFieldNamesToCodeNames falls back to it too.
291
+ previousGlobalProvider = Metadata.Provider;
292
+ useFixture();
293
+ });
294
+
295
+ afterEach(() => {
296
+ Metadata.Provider = previousGlobalProvider;
297
+ });
298
+
299
+ it('promotion: an existing parent key on a child create UPDATES nothing on the parent and INSERTs only the child', async () => {
300
+ const result = await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, IsHouseTrained: true }, provider, payload);
301
+
302
+ // The parent row was looked up by the incoming key...
303
+ expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
304
+ // ...and NOT re-created. Before the fix this list began with
305
+ // { Entity: 'Animals', Type: 'create' } — a duplicate copy of Biscuit.
306
+ expect(provider.saves.filter(s => s.Entity === 'Animals')).toEqual([]);
307
+ // The child is created on the SHARED key.
308
+ expect(provider.saves).toEqual([
309
+ expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
310
+ ]);
311
+ expect(provider.saves[0].Values['IsHouseTrained']).toBe(true);
312
+ expect(result?.['ID']).toBe(EXISTING_ANIMAL_ID);
313
+ });
314
+
315
+ it('promotion: parent fields sent alongside the key are applied as an UPDATE of the existing parent', async () => {
316
+ await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit II', IsHouseTrained: true }, provider, payload);
317
+
318
+ expect(provider.saves).toEqual([
319
+ expect.objectContaining({ Entity: 'Animals', Type: 'update', ID: EXISTING_ANIMAL_ID }),
320
+ expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
321
+ ]);
322
+ expect(provider.saves[0].Values['Name']).toBe('Biscuit II');
323
+ // The loaded value the input did NOT touch survives — the parent was loaded, not blanked.
324
+ expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000001');
325
+ });
326
+
327
+ it('whole-chain create: no key in the input creates parent AND child on one fresh key, without a lookup', async () => {
328
+ await new CreateProbe().Create('Dogs', { Name: 'Pretzel', IsHouseTrained: false }, provider, payload);
329
+
330
+ expect(provider.loads).toEqual([]);
331
+ expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Animals', 'create'], ['Dogs', 'create']]);
332
+ const sharedKey = provider.saves[0].ID;
333
+ expect(typeof sharedKey).toBe('string');
334
+ expect(sharedKey).not.toBe(EXISTING_ANIMAL_ID);
335
+ expect(provider.saves[1].ID).toBe(sharedKey);
336
+ });
337
+
338
+ it('whole-chain create with a caller-supplied key that matches no row still creates the full chain on that key', async () => {
339
+ await new CreateProbe().Create('Dogs', { ID: MISSING_ANIMAL_ID, Name: 'Ghost', IsHouseTrained: false }, provider, payload);
340
+
341
+ expect(provider.loads).toEqual([{ Entity: 'Animals', ID: MISSING_ANIMAL_ID }]);
342
+ expect(provider.saves).toEqual([
343
+ expect.objectContaining({ Entity: 'Animals', Type: 'create', ID: MISSING_ANIMAL_ID }),
344
+ expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: MISSING_ANIMAL_ID }),
345
+ ]);
346
+ expect(provider.saves[0].Values['Name']).toBe('Ghost');
347
+ });
348
+
349
+ it('non-IS-A create is untouched: no lookup, one create', async () => {
350
+ await new CreateProbe().Create('Toys', { Name: 'Squeaky Bone' }, provider, payload);
351
+
352
+ expect(provider.loads).toEqual([]);
353
+ expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Toys', 'create']]);
354
+ });
355
+
356
+ it('overlapping subtypes (AllowMultipleSubtypes): promotion still attaches to the existing parent and creates only the child', async () => {
357
+ // Overlapping hierarchies exist precisely to be added to incrementally, so promotion is the
358
+ // natural operation there. The disjoint guard does not run; the attach path is the same.
359
+ useFixture({ overlapping: true });
360
+
361
+ const result = await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, IsHouseTrained: true }, provider, payload);
362
+
363
+ expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
364
+ expect(provider.saves.filter(s => s.Entity === 'Animals')).toEqual([]);
365
+ expect(provider.saves).toEqual([
366
+ expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
367
+ ]);
368
+ expect(result?.['ID']).toBe(EXISTING_ANIMAL_ID);
369
+ });
370
+
371
+ describe('field-level security: the same parent field on a child create meets two different verbs', () => {
372
+ // With FLS on, parent fields sent on a child-create input are enforced by whichever verb the
373
+ // PARENT's save runs — and that is decided by whether the parent row already exists:
374
+ // promotion → the parent is LOADED, saves as an UPDATE → CheckFieldLevelUpdatePermissions REJECTS;
375
+ // whole-chain create → the parent is NEW, saves as a CREATE → ApplyFieldLevelCreateSuppression OMITS.
376
+ // That fork is deliberate (a promotion really is an update of the parent) and these pin it.
377
+
378
+ it('promotion: a parent field the user cannot UPDATE rejects the whole save, before any SQL', async () => {
379
+ useFixture({ nameLockedByFls: true });
380
+
381
+ await expect(
382
+ new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit II', IsHouseTrained: true }, provider, payload)
383
+ ).rejects.toThrow(/permission to update field 'Name'/);
384
+
385
+ expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
386
+ expect(provider.saves).toEqual([]);
387
+ });
388
+
389
+ it('promotion: parent fields the user CAN update still go through as an UPDATE', async () => {
390
+ useFixture({ nameLockedByFls: true });
391
+
392
+ await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, MicrochipNumber: '985112000000002', IsHouseTrained: true }, provider, payload);
393
+
394
+ expect(provider.saves).toEqual([
395
+ expect.objectContaining({ Entity: 'Animals', Type: 'update', ID: EXISTING_ANIMAL_ID }),
396
+ expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
397
+ ]);
398
+ expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000002');
399
+ });
400
+
401
+ it('whole-chain create: the same parent field the user cannot CREATE is silently OMITTED, and the create proceeds', async () => {
402
+ useFixture({ nameLockedByFls: true });
403
+
404
+ await new CreateProbe().Create('Dogs', { Name: 'Pretzel', MicrochipNumber: '985112000000003', IsHouseTrained: false }, provider, payload);
405
+
406
+ expect(provider.loads).toEqual([]);
407
+ expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Animals', 'create'], ['Dogs', 'create']]);
408
+ expect(provider.saves[0].SuppressedOnCreate).toEqual(['Name']);
409
+ expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000003');
410
+ });
411
+ });
412
+ });