@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,165 @@
1
+ // ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+
5
+ /**
6
+ * `RunViewsWithCacheCheck` is the transport `RunViews` switches to when any view in the batch is
7
+ * `CacheLocal`, so it carries the same client-supplied clauses as `RunViews`. These tests pin that
8
+ * it applies the same GraphQL-boundary protections before the provider is called: the base-view
9
+ * AST screen on ExtraFilter / OrderBy / OverrideExcludeFilter, and the API-key scope check.
10
+ *
11
+ * Every payload here is harmless (stacked `SELECT`s); the point is only whether it reaches the
12
+ * provider. The provider's own keyword denylist accepts the bracket-identifier payload, because a
13
+ * quote inside `[a']` hides the rest of the text from its literal stripper.
14
+ */
15
+
16
+ const { authorize } = vi.hoisted(() => ({ authorize: vi.fn() }));
17
+
18
+ vi.mock('@memberjunction/api-keys', async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import('@memberjunction/api-keys')>();
20
+ return { ...actual, GetAPIKeyEngine: () => ({ Authorize: authorize }) };
21
+ });
22
+
23
+ import type { DatabaseProviderBase, EntityInfo, RunViewWithCacheCheckParams, UserInfo } from '@memberjunction/core';
24
+ import { UserCache } from '@memberjunction/generic-database-provider';
25
+ import { RunDynamicViewInput, RunViewResolver, RunViewWithCacheCheckInput } from '../generic/RunViewResolver.js';
26
+ import type { AppContext, UserPayload } from '../types.js';
27
+
28
+ /** A stacked statement hidden from the keyword denylist by a quote inside a bracket identifier. */
29
+ const BRACKET_STACKED =
30
+ "1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM __mj.vwUserViews WHERE (1 = (SELECT 1 AS [b'])";
31
+
32
+ const USER = { ID: 'user-1', Email: 'reader@example.com', Name: 'Reader' } as UserInfo;
33
+
34
+ function entity(name: string, schema: string, baseView: string): EntityInfo {
35
+ return {
36
+ Name: name,
37
+ SchemaName: schema,
38
+ BaseView: baseView,
39
+ PrimaryKeys: [{ Name: 'ID' }],
40
+ GetUserPermisions: () => ({ CanRead: true }),
41
+ GetEffectiveRowFilterWhereClause: () => '',
42
+ GetDeniedReadFields: () => new Set<string>(),
43
+ } as unknown as EntityInfo;
44
+ }
45
+
46
+ /** Provider double: the metadata the screen reads, and a cache-check method that records calls. */
47
+ function fakeProvider() {
48
+ const calls: RunViewWithCacheCheckParams[][] = [];
49
+ const entities = [
50
+ entity('MJ: User Views', '__mj', 'vwUserViews'),
51
+ entity('MJ: Users', '__mj', 'vwUsers'),
52
+ ];
53
+ const provider = {
54
+ Entities: entities,
55
+ EntityByName: (name: string) => entities.find((e) => e.Name === name),
56
+ RunViewsWithCacheCheck: async (params: RunViewWithCacheCheckParams[]) => {
57
+ calls.push(params);
58
+ return { success: true, results: [] };
59
+ },
60
+ };
61
+ return { provider: provider as unknown as DatabaseProviderBase, calls };
62
+ }
63
+
64
+ function context(provider: DatabaseProviderBase, payload: Partial<UserPayload> = {}): AppContext {
65
+ const userPayload = { email: USER.Email, userRecord: USER, ...payload } as UserPayload;
66
+ return { providers: [{ type: 'Read-Only', provider }], userPayload } as unknown as AppContext;
67
+ }
68
+
69
+ function cacheCheckInput(params: Partial<RunDynamicViewInput>): RunViewWithCacheCheckInput[] {
70
+ const dynamic = Object.assign(new RunDynamicViewInput(), { EntityName: 'MJ: User Views', ...params });
71
+ return [Object.assign(new RunViewWithCacheCheckInput(), { params: dynamic })];
72
+ }
73
+
74
+ describe('RunViewsWithCacheCheck — GraphQL-boundary clause screen', () => {
75
+ it('refuses a stacked statement hidden behind a bracket identifier, before the provider runs', async () => {
76
+ const { provider, calls } = fakeProvider();
77
+
78
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
79
+ cacheCheckInput({ ExtraFilter: BRACKET_STACKED }),
80
+ context(provider),
81
+ );
82
+
83
+ expect(calls).toHaveLength(0);
84
+ expect(result.success).toBe(false);
85
+ expect(result.errorMessage).toMatch(/Invalid ExtraFilter: multiple statements/);
86
+ });
87
+
88
+ it('refuses a subquery against a base table', async () => {
89
+ const { provider, calls } = fakeProvider();
90
+
91
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
92
+ cacheCheckInput({ ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')` }),
93
+ context(provider),
94
+ );
95
+
96
+ expect(calls).toHaveLength(0);
97
+ expect(result.success).toBe(false);
98
+ expect(result.errorMessage).toMatch(/entity base view/);
99
+ });
100
+
101
+ it('screens OrderBy and OverrideExcludeFilter too', async () => {
102
+ for (const params of [
103
+ { OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
104
+ { OverrideExcludeFilter: BRACKET_STACKED },
105
+ ]) {
106
+ const { provider, calls } = fakeProvider();
107
+
108
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(cacheCheckInput(params), context(provider));
109
+
110
+ expect(calls).toHaveLength(0);
111
+ expect(result.success).toBe(false);
112
+ }
113
+ });
114
+
115
+ it('passes a filter that only reads entity base views through to the provider', async () => {
116
+ const { provider, calls } = fakeProvider();
117
+ const filter = `UserID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'o''brien@example.com')`;
118
+
119
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
120
+ cacheCheckInput({ ExtraFilter: filter, OrderBy: '[Name] DESC' }),
121
+ context(provider),
122
+ );
123
+
124
+ expect(result.success).toBe(true);
125
+ expect(calls).toHaveLength(1);
126
+ expect(calls[0][0].params.ExtraFilter).toBe(filter);
127
+ });
128
+ });
129
+
130
+ describe('RunViewsWithCacheCheck — API-key scope', () => {
131
+ beforeEach(() => {
132
+ authorize.mockReset();
133
+ vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue({ ID: 'system', Email: 'system@example.com' } as UserInfo);
134
+ });
135
+
136
+ it('refuses an API key without the batch view scope, before the provider runs', async () => {
137
+ authorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
138
+ const { provider, calls } = fakeProvider();
139
+
140
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
141
+ cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
142
+ context(provider, { apiKeyHash: 'hash-of-a-narrow-key' }),
143
+ );
144
+
145
+ expect(calls).toHaveLength(0);
146
+ expect(result.success).toBe(false);
147
+ expect(result.errorMessage).toMatch(/'view:batch' scope/);
148
+ });
149
+
150
+ it('lets an API key holding the scope through', async () => {
151
+ authorize.mockImplementation(async (_hash: string, _app: string, scope: string) => ({
152
+ Allowed: scope === 'view:batch',
153
+ Reason: scope === 'view:batch' ? 'granted' : 'not full access',
154
+ }));
155
+ const { provider, calls } = fakeProvider();
156
+
157
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
158
+ cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
159
+ context(provider, { apiKeyHash: 'hash-of-a-batch-key' }),
160
+ );
161
+
162
+ expect(result.success).toBe(true);
163
+ expect(calls).toHaveLength(1);
164
+ });
165
+ });
@@ -27,15 +27,18 @@ vi.mock('@memberjunction/ai-agents', () => ({
27
27
  },
28
28
  }));
29
29
 
30
- // --- Mock RunView.FromMetadataProvider so channel sweeps are controllable, while leaving the
31
- // rest of @memberjunction/core intact. ---
30
+ // --- Mock RunView.FromMetadataProvider so channel sweeps and run-id lookups are controllable,
31
+ // while leaving the rest of @memberjunction/core intact. RunViews runs each view through runViewMock. ---
32
32
  const runViewMock = vi.fn();
33
33
  vi.mock('@memberjunction/core', async (importOriginal) => {
34
34
  const actual = await importOriginal<typeof import('@memberjunction/core')>();
35
35
  return {
36
36
  ...actual,
37
37
  RunView: {
38
- FromMetadataProvider: () => ({ RunView: runViewMock }),
38
+ FromMetadataProvider: () => ({
39
+ RunView: runViewMock,
40
+ RunViews: (paramsList: unknown[]) => Promise.all(paramsList.map((params) => runViewMock(params))),
41
+ }),
39
42
  },
40
43
  };
41
44
  });
@@ -100,6 +103,27 @@ function makeSessionEntity(overrides: Partial<FakeEntity> = {}): FakeEntity {
100
103
  };
101
104
  }
102
105
 
106
+ /** Co-agent observability ids as a voice session's Config holds them. */
107
+ const CO_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000c0';
108
+ const PROMPT_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000b0';
109
+ const RUN_STEP_ID = 'a5a5a5a5-0000-4000-8000-0000000000a0';
110
+
111
+ /**
112
+ * Makes the given runs records of the session for the run-id lookups: `runIDs` carry the session's
113
+ * id, and `promptRunID` (when given) is the TargetLogID of the co-agent run's Prompt step.
114
+ */
115
+ function mockSessionRunRecords(runIDs: string[], promptRunID?: string): void {
116
+ runViewMock.mockImplementation(async (params: { EntityName?: string }) => {
117
+ if (params.EntityName === 'MJ: AI Agent Runs') {
118
+ return { Success: true, Results: runIDs.map((ID) => ({ ID })) };
119
+ }
120
+ if (params.EntityName === 'MJ: AI Agent Run Steps') {
121
+ return { Success: true, Results: promptRunID ? [{ ID: RUN_STEP_ID, StepType: 'Prompt', TargetLogID: promptRunID }] : [] };
122
+ }
123
+ return { Success: true, Results: [] };
124
+ });
125
+ }
126
+
103
127
  beforeEach(() => {
104
128
  hasPermissionMock.mockReset();
105
129
  finalizeCoAgentRunMock.mockClear();
@@ -228,8 +252,9 @@ describe('SessionManager.CloseSession', () => {
228
252
  const session = makeSessionEntity({
229
253
  ID: 'session-voice',
230
254
  Status: 'Active',
231
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-5', promptRunID: 'prompt-run-5' }),
255
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
232
256
  });
257
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
233
258
  const { provider } = makeProvider(() => session);
234
259
  const mgr = new SessionManager();
235
260
  const user = makeUser();
@@ -238,7 +263,7 @@ describe('SessionManager.CloseSession', () => {
238
263
 
239
264
  expect(ok).toBe(true);
240
265
  expect(finalizeCoAgentRunMock).toHaveBeenCalledTimes(1);
241
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-5', 'prompt-run-5', user, provider, true, null);
266
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
242
267
  });
243
268
 
244
269
  it('threads the co-agent run-step id into finalize when the session config carries it', async () => {
@@ -247,11 +272,12 @@ describe('SessionManager.CloseSession', () => {
247
272
  Status: 'Active',
248
273
  Config_: JSON.stringify({
249
274
  targetAgentID: 't1',
250
- coAgentRunID: 'co-run-6',
251
- promptRunID: 'prompt-run-6',
252
- coAgentRunStepID: 'run-step-6',
275
+ coAgentRunID: CO_RUN_ID,
276
+ promptRunID: PROMPT_RUN_ID,
277
+ coAgentRunStepID: RUN_STEP_ID,
253
278
  }),
254
279
  });
280
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
255
281
  const { provider } = makeProvider(() => session);
256
282
  const mgr = new SessionManager();
257
283
  const user = makeUser();
@@ -259,7 +285,7 @@ describe('SessionManager.CloseSession', () => {
259
285
  const ok = await mgr.CloseSession('session-voice-step', user, provider);
260
286
 
261
287
  expect(ok).toBe(true);
262
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-6', 'prompt-run-6', user, provider, true, 'run-step-6');
288
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, RUN_STEP_ID);
263
289
  });
264
290
 
265
291
  it('finalizes as the SYSTEM user when a scoped anonymous owner closes their session (issue #3371)', async () => {
@@ -274,8 +300,9 @@ describe('SessionManager.CloseSession', () => {
274
300
  const session = makeSessionEntity({
275
301
  ID: 'session-anon',
276
302
  Status: 'Active',
277
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-9', promptRunID: 'prompt-run-9' }),
303
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
278
304
  });
305
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
279
306
  const { provider } = makeProvider(() => session);
280
307
  const mgr = new SessionManager();
281
308
 
@@ -284,7 +311,7 @@ describe('SessionManager.CloseSession', () => {
284
311
  expect(ok).toBe(true);
285
312
  // The session-close writes themselves stay on the caller — only finalize elevates.
286
313
  expect(session.Status).toBe('Closed');
287
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-9', 'prompt-run-9', systemUser, provider, true, null);
314
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, systemUser, provider, true, null);
288
315
  });
289
316
 
290
317
  it('FAILS CLOSED — finalizes as the anonymous caller when no system user is available', async () => {
@@ -297,14 +324,15 @@ describe('SessionManager.CloseSession', () => {
297
324
  const session = makeSessionEntity({
298
325
  ID: 'session-anon-2',
299
326
  Status: 'Active',
300
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-10', promptRunID: null }),
327
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: null }),
301
328
  });
329
+ mockSessionRunRecords([CO_RUN_ID]);
302
330
  const { provider } = makeProvider(() => session);
303
331
  const mgr = new SessionManager();
304
332
 
305
333
  await mgr.CloseSession('session-anon-2', anonUser, provider);
306
334
 
307
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-10', null, anonUser, provider, true, null);
335
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, null, anonUser, provider, true, null);
308
336
  });
309
337
 
310
338
  it('does not finalize when the session config has no run ids (target only)', async () => {
@@ -334,8 +362,9 @@ describe('SessionManager.CloseSession', () => {
334
362
  const session = makeSessionEntity({
335
363
  ID: 'session-injected',
336
364
  Status: 'Active',
337
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-injected', promptRunID: 'prompt-run-injected' }),
365
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
338
366
  });
367
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
339
368
  const { provider } = makeProvider(() => session);
340
369
  const mgr = new SessionManager(injectedService);
341
370
  const user = makeUser();
@@ -345,7 +374,7 @@ describe('SessionManager.CloseSession', () => {
345
374
  expect(ok).toBe(true);
346
375
  // The finalize call landed on the INJECTED instance, never the module-default one.
347
376
  expect(injectedFinalizeMock).toHaveBeenCalledTimes(1);
348
- expect(injectedFinalizeMock).toHaveBeenCalledWith('co-run-injected', 'prompt-run-injected', user, provider, true, null);
377
+ expect(injectedFinalizeMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
349
378
  expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
350
379
  });
351
380
 
@@ -353,8 +382,9 @@ describe('SessionManager.CloseSession', () => {
353
382
  const session = makeSessionEntity({
354
383
  ID: 'session-default-svc',
355
384
  Status: 'Active',
356
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-default', promptRunID: 'prompt-run-default' }),
385
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
357
386
  });
387
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
358
388
  const { provider } = makeProvider(() => session);
359
389
  const mgr = new SessionManager(); // no injection — e.g. SessionJanitor, telephony services
360
390
  const user = makeUser();
@@ -362,7 +392,7 @@ describe('SessionManager.CloseSession', () => {
362
392
  const ok = await mgr.CloseSession('session-default-svc', user, provider);
363
393
 
364
394
  expect(ok).toBe(true);
365
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-default', 'prompt-run-default', user, provider, true, null);
395
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
366
396
  });
367
397
 
368
398
  it('stamps CloseReason = Explicit by default (untouched call sites get the right reason)', async () => {
@@ -626,3 +656,50 @@ describe('SessionManager — server-side channel plugin lifecycle notifications'
626
656
  expect(session.Status).toBe('Closed');
627
657
  });
628
658
  });
659
+
660
+ // A session's Config is writable by its owner (generic CRUD on MJ: AI Agent Sessions), so the run
661
+ // ids in it are acted on only when they are UUIDs of records that belong to the session.
662
+ describe('SessionManager — run ids in the owner-writable session Config', () => {
663
+ const SESSION_ID = 'c3c3c3c3-1000-4000-8000-000000000001';
664
+ const FOREIGN_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f1';
665
+ const FOREIGN_PROMPT_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f2';
666
+ const FOREIGN_STEP_ID = 'c3c3c3c3-1000-4000-8000-0000000000f3';
667
+ /** Harmless text that is not a UUID and would change the meaning of a quoted filter. */
668
+ const NOT_A_UUID = "x' OR '1'='1";
669
+
670
+ function issuedFilters(): string[] {
671
+ return runViewMock.mock.calls.map(([params]) => (params as { ExtraFilter?: string }).ExtraFilter ?? '');
672
+ }
673
+
674
+ it('CloseSession does not finalize run ids that are not records of the session', async () => {
675
+ const session = makeSessionEntity({
676
+ ID: SESSION_ID,
677
+ Status: 'Active',
678
+ Config_: JSON.stringify({
679
+ targetAgentID: 't1',
680
+ coAgentRunID: FOREIGN_RUN_ID,
681
+ promptRunID: FOREIGN_PROMPT_RUN_ID,
682
+ coAgentRunStepID: FOREIGN_STEP_ID,
683
+ }),
684
+ });
685
+ const { provider } = makeProvider(() => session);
686
+
687
+ await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
688
+
689
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
690
+ });
691
+
692
+ it('CloseSession does not finalize run ids that are not UUIDs, and never puts them in a filter', async () => {
693
+ const session = makeSessionEntity({
694
+ ID: SESSION_ID,
695
+ Status: 'Active',
696
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: NOT_A_UUID, promptRunID: NOT_A_UUID }),
697
+ });
698
+ const { provider } = makeProvider(() => session);
699
+
700
+ await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
701
+
702
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
703
+ expect(issuedFilters().filter((f) => f.includes(NOT_A_UUID))).toEqual([]);
704
+ });
705
+ });
@@ -0,0 +1,251 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { IMetadataProvider, RunViewParams, UserInfo } from '@memberjunction/core';
3
+ import { UserCache } from '@memberjunction/generic-database-provider';
4
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
5
+
6
+ const SESSION_ID = 'd3d3d3d3-0000-4000-8000-000000000001';
7
+ const CO_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000002';
8
+ const PROMPT_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000003';
9
+ const STEP_ID = 'd3d3d3d3-0000-4000-8000-000000000004';
10
+ const PAUSED_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000005';
11
+ const USER = { ID: 'd3d3d3d3-0000-4000-8000-0000000000aa' } as unknown as UserInfo;
12
+
13
+ /** Rows each lookup returns, keyed by entity name. */
14
+ type Rows = Record<string, Array<Record<string, unknown>>>;
15
+
16
+ function makeProvider(rows: Rows, success = true): { provider: IMetadataProvider; runViews: ReturnType<typeof vi.fn> } {
17
+ const runViews = vi.fn(async (paramsList: RunViewParams[]) =>
18
+ paramsList.map((params) => ({
19
+ Success: success,
20
+ Results: success ? rows[params.EntityName ?? ''] ?? [] : [],
21
+ ErrorMessage: success ? '' : 'lookup failed',
22
+ })),
23
+ );
24
+ return { provider: { RunViews: runViews } as unknown as IMetadataProvider, runViews };
25
+ }
26
+
27
+ function issuedViews(runViews: ReturnType<typeof vi.fn>): RunViewParams[] {
28
+ return runViews.mock.calls.flatMap(([paramsList]) => paramsList as RunViewParams[]);
29
+ }
30
+
31
+ beforeEach(() => {
32
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
33
+ });
34
+
35
+ afterEach(() => {
36
+ UserCache.Instance.SetUsers([]);
37
+ });
38
+
39
+ describe('SessionRunIDVerifier.Verify', () => {
40
+ it('keeps nothing, and runs no lookup, when no claim is a UUID', async () => {
41
+ const { provider, runViews } = makeProvider({});
42
+
43
+ const result = await new SessionRunIDVerifier().Verify(
44
+ SESSION_ID,
45
+ { CoAgentRunID: "x' OR '1'='1", PromptRunID: 'prompt-run-1', PendingFeedbackRunID: '' },
46
+ USER,
47
+ provider,
48
+ );
49
+
50
+ expect(result).toEqual({});
51
+ expect(runViews).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('looks up the claimed runs within the session and keeps only the ones it finds', async () => {
55
+ const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
56
+
57
+ const result = await new SessionRunIDVerifier().Verify(
58
+ SESSION_ID,
59
+ { CoAgentRunID: CO_RUN_ID, PendingFeedbackRunID: PAUSED_RUN_ID },
60
+ USER,
61
+ provider,
62
+ );
63
+
64
+ expect(result.CoAgentRunID).toBe(CO_RUN_ID);
65
+ expect(result.PendingFeedbackRunID).toBeUndefined();
66
+ const [runsView] = issuedViews(runViews);
67
+ expect(runsView.EntityName).toBe('MJ: AI Agent Runs');
68
+ expect(runsView.ExtraFilter).toBe(`AgentSessionID='${SESSION_ID}' AND ID IN ('${CO_RUN_ID}', '${PAUSED_RUN_ID}')`);
69
+ // No system user is cached here, so the lookup runs as the acting user.
70
+ expect(runViews.mock.calls[0][1]).toBe(USER);
71
+ });
72
+
73
+ it('runs the lookup as the system user when one is available', async () => {
74
+ // Row-level security can hide a session's own runs from its owner: a web-widget guest's
75
+ // delegated runs carry no ConversationID, so the guest's run filter does not match them.
76
+ const systemUser = { ID: UserCache.Instance.SYSTEM_USER_ID, Email: 'system@example.com' } as unknown as UserInfo;
77
+ UserCache.Instance.SetUsers([systemUser]);
78
+ const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID }] });
79
+
80
+ const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
81
+
82
+ expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
83
+ expect(runViews.mock.calls[0][1]).toBe(systemUser);
84
+ });
85
+
86
+ it('keeps the prompt run and step that belong to the session\'s co-agent run', async () => {
87
+ const { provider, runViews } = makeProvider({
88
+ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
89
+ 'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
90
+ });
91
+
92
+ const result = await new SessionRunIDVerifier().Verify(
93
+ SESSION_ID,
94
+ { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
95
+ USER,
96
+ provider,
97
+ );
98
+
99
+ expect(result).toEqual({ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: undefined });
100
+ const stepsView = issuedViews(runViews).find((view) => view.EntityName === 'MJ: AI Agent Run Steps');
101
+ expect(stepsView?.ExtraFilter).toBe(`AgentRunID='${CO_RUN_ID}' AND (ID='${STEP_ID}' OR TargetLogID='${PROMPT_RUN_ID}')`);
102
+ });
103
+
104
+ it('drops a prompt run that is the target of a step other than a Prompt step', async () => {
105
+ const { provider } = makeProvider({
106
+ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
107
+ 'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Tool', TargetLogID: PROMPT_RUN_ID }],
108
+ });
109
+
110
+ const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
111
+
112
+ expect(result.PromptRunID).toBeUndefined();
113
+ });
114
+
115
+ it('drops the prompt run and step when the co-agent run is not the session\'s', async () => {
116
+ const { provider } = makeProvider({
117
+ 'MJ: AI Agent Runs': [],
118
+ 'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
119
+ });
120
+
121
+ const result = await new SessionRunIDVerifier().Verify(
122
+ SESSION_ID,
123
+ { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
124
+ USER,
125
+ provider,
126
+ );
127
+
128
+ expect(result).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
129
+ });
130
+
131
+ it('matches ids case-insensitively', async () => {
132
+ const { provider } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID.toUpperCase() }] });
133
+
134
+ const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
135
+
136
+ expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
137
+ });
138
+
139
+ it('looks up the same claims again on every call', async () => {
140
+ const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
141
+ const verifier = new SessionRunIDVerifier();
142
+
143
+ await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
144
+ const second = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
145
+
146
+ expect(second.CoAgentRunID).toBe(CO_RUN_ID);
147
+ expect(runViews).toHaveBeenCalledTimes(2);
148
+ });
149
+
150
+ it('reads the runs and steps from the database, not from a cached view', async () => {
151
+ const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
152
+
153
+ await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
154
+
155
+ const views = issuedViews(runViews);
156
+ expect(views.map((view) => view.EntityName)).toEqual(['MJ: AI Agent Runs', 'MJ: AI Agent Run Steps']);
157
+ expect(views.every((view) => view.BypassCache === true)).toBe(true);
158
+ });
159
+
160
+ it('keeps nothing when a lookup fails, and looks up again next time', async () => {
161
+ const { provider, runViews } = makeProvider({}, false);
162
+ const verifier = new SessionRunIDVerifier();
163
+
164
+ const first = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
165
+ await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
166
+
167
+ expect(first).toEqual({});
168
+ expect(runViews).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it('keeps nothing, without throwing, when the provider throws', async () => {
172
+ const provider = { RunViews: vi.fn(async () => { throw new Error('connection lost'); }) } as unknown as IMetadataProvider;
173
+
174
+ await expect(new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider)).resolves.toEqual({});
175
+ });
176
+ });
177
+
178
+ /**
179
+ * A run's `AgentSessionID` and a step's `AgentRunID` and `TargetLogID` can change after a lookup (the
180
+ * generated update mutations accept them), so each call must decide from what the rows say now.
181
+ */
182
+ describe('SessionRunIDVerifier.Verify — relationships that change between calls', () => {
183
+ const CLAIMS = { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: PAUSED_RUN_ID };
184
+
185
+ /** Both runs are records of the session, and the step is the co-agent run's Prompt step for the prompt run. */
186
+ function linkedRows(): Rows {
187
+ return {
188
+ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }, { ID: PAUSED_RUN_ID }],
189
+ 'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
190
+ };
191
+ }
192
+
193
+ it('drops runs that stopped being records of the session since the last call', async () => {
194
+ const rows = linkedRows();
195
+ const { provider } = makeProvider(rows);
196
+ const verifier = new SessionRunIDVerifier();
197
+ const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
198
+
199
+ // The runs were moved to another session, unlinked or deleted.
200
+ rows['MJ: AI Agent Runs'] = [];
201
+ const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
202
+
203
+ expect(first).toEqual(CLAIMS);
204
+ expect(second).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
205
+ });
206
+
207
+ it('drops the prompt run and step once the step is no longer linked to the co-agent run', async () => {
208
+ const rows = linkedRows();
209
+ const { provider } = makeProvider(rows);
210
+ const verifier = new SessionRunIDVerifier();
211
+ const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
212
+
213
+ rows['MJ: AI Agent Run Steps'] = [];
214
+ const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
215
+
216
+ expect(first).toEqual(CLAIMS);
217
+ expect(second).toEqual({ ...CLAIMS, PromptRunID: undefined, CoAgentRunStepID: undefined });
218
+ });
219
+
220
+ it('still keeps a relationship that has not changed on the next call', async () => {
221
+ const { provider } = makeProvider(linkedRows());
222
+ const verifier = new SessionRunIDVerifier();
223
+
224
+ await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
225
+ const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
226
+
227
+ expect(second).toEqual(CLAIMS);
228
+ });
229
+ });
230
+
231
+ describe('SessionRunIDVerifier.Verify — lookups as the acting user', () => {
232
+ it('does not give a user the result of a lookup made for another user', async () => {
233
+ // No system user is cached, so each lookup runs as the acting user, and only USER can see the run.
234
+ const otherUser = { ID: 'd3d3d3d3-0000-4000-8000-0000000000bb' } as unknown as UserInfo;
235
+ const runViews = vi.fn(async (paramsList: RunViewParams[], user: UserInfo) =>
236
+ paramsList.map((params) => {
237
+ const visible = user === USER && params.EntityName === 'MJ: AI Agent Runs';
238
+ return { Success: true, Results: visible ? [{ ID: CO_RUN_ID }] : [], ErrorMessage: '' };
239
+ }),
240
+ );
241
+ const provider = { RunViews: runViews } as unknown as IMetadataProvider;
242
+ const verifier = new SessionRunIDVerifier();
243
+
244
+ const forUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
245
+ const forOtherUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, otherUser, provider);
246
+
247
+ expect(forUser.CoAgentRunID).toBe(CO_RUN_ID);
248
+ expect(forOtherUser.CoAgentRunID).toBeUndefined();
249
+ expect(runViews.mock.calls[1]?.[1]).toBe(otherUser);
250
+ });
251
+ });