@memberjunction/server 6.1.4 → 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 (227) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionManager.d.ts +5 -2
  4. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionManager.js +17 -8
  6. package/dist/agentSessions/SessionManager.js.map +1 -1
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  10. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  11. package/dist/agentSessions/index.d.ts +1 -0
  12. package/dist/agentSessions/index.d.ts.map +1 -1
  13. package/dist/agentSessions/index.js +1 -0
  14. package/dist/agentSessions/index.js.map +1 -1
  15. package/dist/auth/actionAuthorization.d.ts +40 -0
  16. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  17. package/dist/auth/actionAuthorization.js +94 -0
  18. package/dist/auth/actionAuthorization.js.map +1 -0
  19. package/dist/auth/exampleNewUserSubClass.js +2 -1
  20. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  22. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  23. package/dist/auth/magicLink/MagicLinkService.js +55 -38
  24. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  25. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  26. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  27. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  28. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  29. package/dist/auth/newUsers.d.ts.map +1 -1
  30. package/dist/auth/newUsers.js +15 -11
  31. package/dist/auth/newUsers.js.map +1 -1
  32. package/dist/context.d.ts +6 -0
  33. package/dist/context.d.ts.map +1 -1
  34. package/dist/context.js +24 -34
  35. package/dist/context.js.map +1 -1
  36. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  37. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  38. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  40. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.js +59 -19
  42. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  43. package/dist/generic/ResolverBase.d.ts +95 -11
  44. package/dist/generic/ResolverBase.d.ts.map +1 -1
  45. package/dist/generic/ResolverBase.js +186 -76
  46. package/dist/generic/ResolverBase.js.map +1 -1
  47. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  48. package/dist/generic/RunViewResolver.js +12 -0
  49. package/dist/generic/RunViewResolver.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +44 -45
  53. package/dist/index.js.map +1 -1
  54. package/dist/isolatedProvider.d.ts +20 -0
  55. package/dist/isolatedProvider.d.ts.map +1 -0
  56. package/dist/isolatedProvider.js +22 -0
  57. package/dist/isolatedProvider.js.map +1 -0
  58. package/dist/multiTenancy/index.d.ts.map +1 -1
  59. package/dist/multiTenancy/index.js +22 -2
  60. package/dist/multiTenancy/index.js.map +1 -1
  61. package/dist/postgresqlCompat.d.ts +8 -0
  62. package/dist/postgresqlCompat.d.ts.map +1 -0
  63. package/dist/postgresqlCompat.js +19 -0
  64. package/dist/postgresqlCompat.js.map +1 -0
  65. package/dist/postgresqlPoolSettings.d.ts +108 -0
  66. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  67. package/dist/postgresqlPoolSettings.js +134 -0
  68. package/dist/postgresqlPoolSettings.js.map +1 -0
  69. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  71. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  72. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  73. package/dist/resolvers/ActionResolver.d.ts +31 -5
  74. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  75. package/dist/resolvers/ActionResolver.js +75 -14
  76. package/dist/resolvers/ActionResolver.js.map +1 -1
  77. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  78. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  79. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  80. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  81. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  82. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  83. package/dist/resolvers/DatasetResolver.js +57 -8
  84. package/dist/resolvers/DatasetResolver.js.map +1 -1
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  86. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  88. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  89. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  90. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  91. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  92. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  94. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  95. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  96. package/dist/resolvers/GetDataResolver.js +13 -1
  97. package/dist/resolvers/GetDataResolver.js.map +1 -1
  98. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  99. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  100. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  101. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
  102. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  103. package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
  104. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  105. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  106. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  108. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  109. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  111. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  112. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTemplateResolver.js +12 -11
  114. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  115. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  116. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  117. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  118. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  119. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  120. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  121. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  122. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  123. package/dist/resolvers/SyncDataResolver.js +6 -2
  124. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  128. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  129. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  130. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  131. package/dist/resolvers/adhocTimeout.js +12 -0
  132. package/dist/resolvers/adhocTimeout.js.map +1 -0
  133. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  134. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  135. package/dist/rest/ViewOperationsHandler.js +29 -17
  136. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  137. package/dist/services/TaskGraphActionRunner.d.ts +21 -8
  138. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  139. package/dist/services/TaskGraphActionRunner.js +43 -16
  140. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  141. package/package.json +92 -92
  142. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  143. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  144. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  145. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  146. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  147. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  148. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  149. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  150. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  151. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  152. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  153. package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
  154. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  155. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  156. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  157. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  158. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  159. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  160. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  161. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  162. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  163. package/src/__tests__/SessionManager.test.ts +94 -17
  164. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  165. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
  166. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  167. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  170. package/src/__tests__/actionAuthorization.test.ts +130 -0
  171. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  172. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  173. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  174. package/src/__tests__/magicLink.test.ts +15 -76
  175. package/src/__tests__/magicLinkService.consume.test.ts +230 -0
  176. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  177. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  178. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  179. package/src/__tests__/multiTenancy.test.ts +1 -1
  180. package/src/__tests__/newUsers.test.ts +62 -5
  181. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  182. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  183. package/src/agentSessions/SessionManager.ts +23 -25
  184. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  185. package/src/agentSessions/index.ts +1 -0
  186. package/src/auth/actionAuthorization.ts +119 -0
  187. package/src/auth/exampleNewUserSubClass.ts +2 -2
  188. package/src/auth/magicLink/MagicLinkService.ts +61 -39
  189. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  190. package/src/auth/newUsers.ts +15 -11
  191. package/src/context.ts +25 -34
  192. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  193. package/src/generic/CacheInvalidationResolver.ts +74 -19
  194. package/src/generic/ResolverBase.ts +207 -80
  195. package/src/generic/RunViewResolver.ts +17 -0
  196. package/src/index.ts +49 -46
  197. package/src/isolatedProvider.ts +22 -0
  198. package/src/multiTenancy/index.ts +24 -2
  199. package/src/postgresqlCompat.ts +21 -0
  200. package/src/postgresqlPoolSettings.ts +213 -0
  201. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  202. package/src/resolvers/ActionResolver.ts +84 -21
  203. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  204. package/src/resolvers/DatasetResolver.ts +70 -10
  205. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  206. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  207. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  208. package/src/resolvers/GetDataResolver.ts +14 -1
  209. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  210. package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
  211. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  212. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  213. package/src/resolvers/RunTemplateResolver.ts +13 -12
  214. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  215. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  216. package/src/resolvers/SyncDataResolver.ts +6 -2
  217. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  218. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  219. package/src/resolvers/adhocTimeout.ts +12 -0
  220. package/src/rest/ViewOperationsHandler.ts +37 -18
  221. package/src/services/TaskGraphActionRunner.ts +45 -16
  222. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  223. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  224. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  225. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  226. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  227. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -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
+ });
@@ -0,0 +1,132 @@
1
+ // type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
2
+ // loaded. MUST precede any import that pulls in the resolver file.
3
+ import 'reflect-metadata';
4
+
5
+ /**
6
+ * `RunTemplate` renders a stored template on demand. Its API-key scope check is a no-op for JWT,
7
+ * magic-link and widget sessions, so the resolver applies its own rule. These tests pin that it
8
+ * refuses scope-limited sessions and callers who cannot read templates before it loads anything.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest';
11
+
12
+ // As in RunDecisionResolver.test.ts: vitest's esbuild transform does not apply
13
+ // `emitDecoratorMetadata`, so the decorators are no-ops and the resolver runs as a plain class.
14
+ vi.mock('type-graphql', () => {
15
+ const noopDecorator = () => () => undefined;
16
+ return {
17
+ Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
18
+ ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
19
+ Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
20
+ Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
21
+ createUnionType: () => class {},
22
+ AuthorizationError: class AuthorizationError extends Error {
23
+ constructor(message?: string) {
24
+ super(message);
25
+ this.name = 'AuthorizationError';
26
+ }
27
+ },
28
+ Float: class {}, Int: class {}, ID: class {},
29
+ };
30
+ });
31
+
32
+ import { EntityInfo, UserInfo, UserRoleInfo } from '@memberjunction/core';
33
+ import { RunTemplateResolver } from '../resolvers/RunTemplateResolver.js';
34
+ import type { AppContext, UserPayload } from '../types.js';
35
+
36
+ const UI_ROLE_ID = '0b9b7b55-0000-4000-8000-0000000000a1';
37
+ const TEMPLATE_ID = '0b9b7b55-0000-4000-8000-0000000000c1';
38
+
39
+ /** An entity whose read permission is granted to the given roles. */
40
+ function entityReadableBy(name: string, roleIds: string[]): EntityInfo {
41
+ const entityId = `entity-${name}`;
42
+ return new EntityInfo({
43
+ ID: entityId, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: `vw${name}`, IncludeInAPI: true,
44
+ EntityPermissions: roleIds.map((roleId, index) => ({
45
+ ID: `${entityId}-permission-${index}`, EntityID: entityId, RoleID: roleId,
46
+ CanCreate: false, CanRead: true, CanUpdate: false, CanDelete: false, Type: 'Allow',
47
+ })),
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Metadata plus the one data call the resolver makes first. The data call fails on purpose, so a
53
+ * test can tell whether the resolver got past authorization without needing a database.
54
+ */
55
+ class FakeProvider {
56
+ public readonly EntityObjectRequests: string[] = [];
57
+
58
+ constructor(private readonly entities: EntityInfo[]) {}
59
+
60
+ public EntityByName(name: string): EntityInfo | undefined {
61
+ return this.entities.find((e) => e.Name.trim().toLowerCase() === name.trim().toLowerCase());
62
+ }
63
+
64
+ public async GetEntityObject(entityName: string): Promise<never> {
65
+ this.EntityObjectRequests.push(entityName);
66
+ throw new Error(`reached data access for ${entityName}`);
67
+ }
68
+ }
69
+
70
+ function makeUser(roleIds: string[]): UserInfo {
71
+ const userId = '0b9b7b55-0000-4000-8000-0000000000b1';
72
+ return new UserInfo(null, {
73
+ ID: userId, Name: 'Template Runner', Email: 'runner@example.com',
74
+ UserRoles: roleIds.map((roleId) => new UserRoleInfo({ UserID: userId, RoleID: roleId })),
75
+ });
76
+ }
77
+
78
+ const readableEverywhere = () => [
79
+ entityReadableBy('MJ: Templates', [UI_ROLE_ID]),
80
+ entityReadableBy('MJ: Template Contents', [UI_ROLE_ID]),
81
+ ];
82
+
83
+ /** A JWT-style session (no API key), so the API-key scope check is a no-op. */
84
+ function contextFor(user: UserInfo, provider: FakeProvider): AppContext {
85
+ const userPayload: UserPayload = { email: user.Email, userRecord: user, sessionId: 'session-1' };
86
+ return { userPayload, providers: [{ type: 'Read-Write', provider }] } as unknown as AppContext;
87
+ }
88
+
89
+ const runTemplate = (user: UserInfo, provider: FakeProvider) =>
90
+ new RunTemplateResolver().RunTemplate(TEMPLATE_ID, contextFor(user, provider));
91
+
92
+ describe('RunTemplate authorization', () => {
93
+ it('refuses an anonymous magic-link guest before loading the template', async () => {
94
+ const provider = new FakeProvider(readableEverywhere());
95
+ const guest = makeUser([UI_ROLE_ID]);
96
+ guest.IsMagicLinkAnonymous = true;
97
+
98
+ const result = await runTemplate(guest, provider);
99
+
100
+ expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
101
+ expect(provider.EntityObjectRequests).toEqual([]);
102
+ });
103
+
104
+ it('refuses a resource-scoped magic-link session before loading the template', async () => {
105
+ const provider = new FakeProvider(readableEverywhere());
106
+ const scoped = makeUser([UI_ROLE_ID]);
107
+ scoped.MagicLinkScope = { ResourceID: '0b9b7b55-0000-4000-8000-0000000000d1', ResourceType: 'Dashboards' };
108
+
109
+ const result = await runTemplate(scoped, provider);
110
+
111
+ expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
112
+ expect(provider.EntityObjectRequests).toEqual([]);
113
+ });
114
+
115
+ it('refuses a user without read permission on MJ: Templates before loading the template', async () => {
116
+ const provider = new FakeProvider(readableEverywhere());
117
+
118
+ const result = await runTemplate(makeUser([]), provider);
119
+
120
+ expect(result).toMatchObject({ success: false, error: 'You do not have permission to read MJ: Templates' });
121
+ expect(provider.EntityObjectRequests).toEqual([]);
122
+ });
123
+
124
+ it('lets a user who can read templates and their content through to the template load', async () => {
125
+ const provider = new FakeProvider(readableEverywhere());
126
+
127
+ const result = await runTemplate(makeUser([UI_ROLE_ID]), provider);
128
+
129
+ expect(result).toMatchObject({ success: false, error: 'reached data access for MJ: Templates' });
130
+ expect(provider.EntityObjectRequests).toEqual(['MJ: Templates']);
131
+ });
132
+ });