@memberjunction/server 5.48.0 → 5.50.0

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 (127) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/auth/newUsers.d.ts.map +1 -1
  11. package/dist/auth/newUsers.js +6 -4
  12. package/dist/auth/newUsers.js.map +1 -1
  13. package/dist/config.d.ts +40 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +12 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts.map +1 -1
  18. package/dist/context.js +28 -2
  19. package/dist/context.js.map +1 -1
  20. package/dist/generated/generated.d.ts +373 -6
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +1975 -39
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/generic/ResolverBase.d.ts.map +1 -1
  25. package/dist/generic/ResolverBase.js +4 -2
  26. package/dist/generic/ResolverBase.js.map +1 -1
  27. package/dist/generic/RunViewResolver.d.ts +1 -0
  28. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  29. package/dist/generic/RunViewResolver.js +12 -0
  30. package/dist/generic/RunViewResolver.js.map +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +73 -11
  33. package/dist/index.js.map +1 -1
  34. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  35. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  36. package/dist/integration/CustomColumnPromoter.js +113 -11
  37. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  38. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  39. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  40. package/dist/integration/EntityMapLifecycle.js +189 -0
  41. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  42. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
  43. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  44. package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
  45. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  46. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  47. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  48. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  49. package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
  50. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  51. package/dist/realtimeWidget/WidgetSessionService.js +23 -8
  52. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  53. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  54. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  55. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  56. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  57. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  58. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  59. package/dist/resolvers/GetDataResolver.js +26 -1
  60. package/dist/resolvers/GetDataResolver.js.map +1 -1
  61. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  62. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  63. package/dist/resolvers/IntegrationDiscoveryResolver.js +531 -64
  64. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  65. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  66. package/dist/resolvers/QueryResolver.js +0 -6
  67. package/dist/resolvers/QueryResolver.js.map +1 -1
  68. package/dist/resolvers/RSUResolver.d.ts +10 -0
  69. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RSUResolver.js +28 -0
  71. package/dist/resolvers/RSUResolver.js.map +1 -1
  72. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  73. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  74. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  75. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  76. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  77. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  78. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  79. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  80. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  81. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  82. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  83. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  84. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  85. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  86. package/dist/rest/OAuthCallbackHandler.js +21 -4
  87. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  88. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  89. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  90. package/dist/telephony/media-upgrade-router.js +17 -1
  91. package/dist/telephony/media-upgrade-router.js.map +1 -1
  92. package/package.json +91 -89
  93. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  94. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  95. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  96. package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
  97. package/src/__tests__/WidgetSessionService.test.ts +96 -0
  98. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  99. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  100. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  101. package/src/__tests__/unifiedAuth.test.ts +18 -3
  102. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  103. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  104. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  105. package/src/auth/newUsers.ts +6 -5
  106. package/src/config.ts +14 -0
  107. package/src/context.ts +28 -2
  108. package/src/generated/generated.ts +1385 -28
  109. package/src/generic/ResolverBase.ts +5 -2
  110. package/src/generic/RunViewResolver.ts +11 -0
  111. package/src/index.ts +75 -11
  112. package/src/integration/CustomColumnPromoter.ts +123 -6
  113. package/src/integration/EntityMapLifecycle.ts +221 -0
  114. package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
  115. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  116. package/src/realtimeWidget/WidgetSessionService.ts +24 -8
  117. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  118. package/src/resolvers/GetDataResolver.ts +30 -1
  119. package/src/resolvers/IntegrationDiscoveryResolver.ts +523 -64
  120. package/src/resolvers/QueryResolver.ts +1 -7
  121. package/src/resolvers/RSUResolver.ts +28 -0
  122. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  123. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  124. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  125. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  126. package/src/rest/OAuthCallbackHandler.ts +22 -4
  127. package/src/telephony/media-upgrade-router.ts +18 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Regression suite for the AdhocQueryResolver pagination / total-row-count bug.
3
+ *
4
+ * Bug #4 — First-page ad-hoc queries reported a TotalRowCount equal to the
5
+ * returned page size, so the artifact viewer's pager never appeared
6
+ * and a >100-row result looked like it only had 100 rows.
7
+ *
8
+ * Two compounding causes in ExecuteAdhocQuery:
9
+ * 1. `usePaging` required `startRow > 0`, so page 1 (StartRow 0)
10
+ * fell back to a TOP-N hard cap instead of OFFSET/FETCH paging.
11
+ * 2. `TotalRowCount` was set to `recordset.length` (the capped page),
12
+ * with no COUNT(*) query — so the true total was never computed.
13
+ *
14
+ * The fix pages whenever `MaxRows > 0` (including StartRow 0) and reads
15
+ * the true total from the RenderPipeline's PagingResult.CountSQL —
16
+ * matching the saved-query path (GenericDatabaseProvider → WrapWithPaging).
17
+ *
18
+ * Same hybrid strategy as AdhocQueryResolver.bugs.test.ts:
19
+ * 1. Source-shape contract tests — assert structural properties of the fix.
20
+ * 2. Pipeline behavior tests — exercise RenderPipeline.Run with the exact
21
+ * first-page inputs the resolver now relies on (data OFFSET/FETCH + count).
22
+ */
23
+ import { describe, it, expect, vi, afterEach } from 'vitest';
24
+ import { readFileSync } from 'fs';
25
+ import { resolve } from 'path';
26
+ import { Metadata } from '@memberjunction/core';
27
+ import { RenderPipeline } from '@memberjunction/generic-database-provider';
28
+
29
+ const ADHOC_RESOLVER_PATH = resolve(__dirname, '../resolvers/AdhocQueryResolver.ts');
30
+
31
+ function readResolverSource(): string {
32
+ return readFileSync(ADHOC_RESOLVER_PATH, 'utf8');
33
+ }
34
+
35
+ function stubMetadata(): void {
36
+ vi.spyOn(Metadata, 'Provider', 'get').mockReturnValue({
37
+ Queries: [],
38
+ QueryDependencies: [],
39
+ } as ReturnType<typeof Metadata.Provider>);
40
+ }
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ // The real-world query from the bug report: no top-level TOP, an ORDER BY,
47
+ // ~2,087 rows in production, requested as page 1 (StartRow 0, MaxRows 100).
48
+ const BUG_REPORT_SQL = `SELECT
49
+ d.ID,
50
+ d.Title,
51
+ d.Value,
52
+ ISNULL(d.DealStage, 'Open (Unspecified)') AS DealStage
53
+ FROM crm.vwDeals d
54
+ WHERE
55
+ d.IsDeleted = 0
56
+ AND (d.DealStage NOT IN ('Closed won', 'Closed lost') OR d.DealStage IS NULL)
57
+ ORDER BY
58
+ d.Value DESC,
59
+ d.CloseDate ASC`;
60
+
61
+ // ════════════════════════════════════════════════════════════════════
62
+ // Source-shape contract tests
63
+ // ════════════════════════════════════════════════════════════════════
64
+
65
+ describe('AdhocQueryResolver pagination source-shape contract', () => {
66
+
67
+ it('must NOT gate paging on `startRow > 0` (page 1 must still page)', () => {
68
+ const src = readResolverSource();
69
+ // The bug: `usePaging = ... && startRow > 0`. After the fix, first-page
70
+ // requests (StartRow 0) must page too, so no `startRow > 0` guard remains.
71
+ expect(/startRow\s*>\s*0/.test(src)).toBe(false);
72
+ });
73
+
74
+ it('must derive TotalRowCount from a COUNT query, not the returned page length', () => {
75
+ const src = readResolverSource();
76
+ // The bug: `TotalRowCount: recordset.length`. After the fix, the total
77
+ // comes from the count query (via a resolved variable), never directly
78
+ // from the page recordset length.
79
+ expect(/TotalRowCount:\s*recordset\.length/.test(src)).toBe(false);
80
+ });
81
+
82
+ it('must consume the RenderPipeline PagingResult.CountSQL', () => {
83
+ const src = readResolverSource();
84
+ expect(/PagingResult\??\.CountSQL/.test(src)).toBe(true);
85
+ });
86
+ });
87
+
88
+ // ════════════════════════════════════════════════════════════════════
89
+ // Pipeline behavior — first page (StartRow 0) yields data + count SQL
90
+ // ════════════════════════════════════════════════════════════════════
91
+
92
+ describe('RenderPipeline first-page paging (the contract the resolver relies on)', () => {
93
+
94
+ it('StartRow 0 + MaxRows 100 produces a PagingResult with OFFSET/FETCH data SQL AND a COUNT(*) count SQL', () => {
95
+ stubMetadata();
96
+
97
+ const result = RenderPipeline.Run(BUG_REPORT_SQL, {
98
+ Platform: 'sqlserver',
99
+ Paging: { StartRow: 0, MaxRows: 100 },
100
+ });
101
+
102
+ // Paging must be applied even at StartRow 0 (the bug caused it to be skipped).
103
+ expect(result.PagingResult).not.toBeNull();
104
+
105
+ // Data SQL: OFFSET/FETCH at the first page, preserving the query's ORDER BY.
106
+ expect(result.FinalSQL).toMatch(/OFFSET\s+0\s+ROWS\s+FETCH\s+NEXT\s+100\s+ROWS\s+ONLY/i);
107
+ expect(result.PagingResult!.DataSQL).toMatch(/OFFSET\s+0\s+ROWS\s+FETCH\s+NEXT\s+100\s+ROWS\s+ONLY/i);
108
+
109
+ // Count SQL: a COUNT(*) over the full (uncapped) set — the true total the
110
+ // viewer's pager needs. It must NOT carry the page's OFFSET/FETCH cap.
111
+ expect(result.PagingResult!.CountSQL).toMatch(/COUNT\(\*\)\s+AS\s+TotalRowCount/i);
112
+ expect(result.PagingResult!.CountSQL).not.toMatch(/FETCH\s+NEXT\s+100\s+ROWS/i);
113
+ });
114
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings } from '../integration/EntityMapLifecycle.js';
3
+
4
+ /**
5
+ * rsuplan "adding and removing tables": removing a table another table relies on must NOT
6
+ * auto-remove the dependent — the broken edge is surfaced as a warning so a consumer can
7
+ * prompt a force-remove (or re-add). These tests pin the pure decision function.
8
+ */
9
+ describe('ComputeRemovedDependencyWarnings', () => {
10
+ const parents = new Map<string, string[]>([
11
+ ['orders', ['Customers']], // Orders depends on Customers
12
+ ['orderitems', ['Orders', 'Products']],
13
+ ['customers', []],
14
+ ['products', []],
15
+ ]);
16
+
17
+ it('warns for each still-active object that depends on a removed object', () => {
18
+ const w = ComputeRemovedDependencyWarnings(['Orders', 'OrderItems', 'Products'], ['Customers'], parents);
19
+ expect(w).toHaveLength(1);
20
+ expect(w[0]).toContain("'Orders'");
21
+ expect(w[0]).toContain("'Customers'");
22
+ expect(w[0]).toContain('force-remove');
23
+ });
24
+
25
+ it('lists every removed parent an active object depends on', () => {
26
+ const w = ComputeRemovedDependencyWarnings(['OrderItems'], ['Orders', 'Products'], parents);
27
+ expect(w).toHaveLength(1);
28
+ expect(w[0]).toContain("'Orders'");
29
+ expect(w[0]).toContain("'Products'");
30
+ });
31
+
32
+ it('is case-insensitive on both sides of the edge', () => {
33
+ const w = ComputeRemovedDependencyWarnings(['ORDERS'], ['customers'], parents);
34
+ expect(w).toHaveLength(1);
35
+ });
36
+
37
+ it('returns nothing when no active object depends on a removed one (leaf removal is clean)', () => {
38
+ expect(ComputeRemovedDependencyWarnings(['Customers', 'Products'], ['Orders'], parents)).toEqual([]);
39
+ });
40
+
41
+ it('returns nothing when nothing was removed or nothing remains active', () => {
42
+ expect(ComputeRemovedDependencyWarnings(['Orders'], [], parents)).toEqual([]);
43
+ expect(ComputeRemovedDependencyWarnings([], ['Customers'], parents)).toEqual([]);
44
+ });
45
+
46
+ it('tolerates objects absent from the dependency map (no persisted IO row)', () => {
47
+ expect(ComputeRemovedDependencyWarnings(['UnknownThing'], ['Customers'], parents)).toEqual([]);
48
+ });
49
+ });
50
+
51
+ /**
52
+ * rsuplan force-remove: "a consumer may want the user to force remove those too" — the
53
+ * transitive closure of dependents that cascades with a removed object when the caller opts in.
54
+ */
55
+ describe('ComputeCascadeRemovalSet', () => {
56
+ const parents = new Map<string, string[]>([
57
+ ['orders', ['Customers']],
58
+ ['orderitems', ['Orders', 'Products']],
59
+ ['shipments', ['OrderItems']], // 3 levels deep
60
+ ['customers', []],
61
+ ['products', []],
62
+ ]);
63
+
64
+ it('cascades direct dependents of a removed object', () => {
65
+ const c = ComputeCascadeRemovalSet(['Orders', 'Products', 'Customers'], ['Products'], parents);
66
+ // Orders depends on Customers (kept) — only OrderItems-style dependents of Products cascade;
67
+ // here Orders does NOT depend on Products, so nothing cascades except true dependents.
68
+ expect(c).toEqual([]);
69
+ });
70
+
71
+ it('cascades the FULL transitive closure (removed → dependent → its dependent …)', () => {
72
+ const c = ComputeCascadeRemovalSet(['Orders', 'OrderItems', 'Shipments', 'Products'], ['Customers'], parents);
73
+ // Customers removed → Orders cascades → OrderItems (depends on Orders) → Shipments (depends on OrderItems)
74
+ expect(c).toEqual(['Orders', 'OrderItems', 'Shipments']);
75
+ });
76
+
77
+ it('is case-insensitive across edges', () => {
78
+ const c = ComputeCascadeRemovalSet(['ORDERS'], ['customers'], parents);
79
+ expect(c).toEqual(['ORDERS']);
80
+ });
81
+
82
+ it('returns nothing when nothing was removed or nothing depends on the removed', () => {
83
+ expect(ComputeCascadeRemovalSet(['Orders'], [], parents)).toEqual([]);
84
+ expect(ComputeCascadeRemovalSet(['Customers', 'Products'], ['Orders'], parents)).toEqual([]);
85
+ });
86
+
87
+ it('never cascades an object that is itself already removed', () => {
88
+ const c = ComputeCascadeRemovalSet(['Orders', 'OrderItems'], ['Customers', 'Orders'], parents);
89
+ expect(c).toEqual(['OrderItems']); // Orders already in the removed set — not double-listed
90
+ });
91
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type express from 'express';
3
+ import { OAuthCallbackHandler } from '../rest/OAuthCallbackHandler.js';
4
+
5
+ /**
6
+ * These tests verify that query-string-derived values interpolated into the OAuth
7
+ * success/error HTML pages are HTML-escaped, preventing reflected XSS (audit bug B6).
8
+ *
9
+ * The page renderers are private, so we reach them through a narrowly-typed view of
10
+ * the instance using the codebase's established `as unknown as` double-assertion pattern.
11
+ */
12
+ interface PageRenderer {
13
+ handleSuccessPage(req: express.Request, res: express.Response): void;
14
+ handleErrorPage(req: express.Request, res: express.Response): void;
15
+ escapeHtml(value: string): string;
16
+ }
17
+
18
+ function makeRenderer(): PageRenderer {
19
+ const handler = new OAuthCallbackHandler({ publicUrl: 'https://example.test' });
20
+ return handler as unknown as PageRenderer;
21
+ }
22
+
23
+ /** Minimal Express request/response doubles that capture the rendered body. */
24
+ function makeReqRes(query: Record<string, string>): {
25
+ req: express.Request;
26
+ res: express.Response;
27
+ getBody: () => string;
28
+ } {
29
+ let body = '';
30
+ const res = {
31
+ status(): express.Response {
32
+ return res as unknown as express.Response;
33
+ },
34
+ send(html: string): express.Response {
35
+ body = html;
36
+ return res as unknown as express.Response;
37
+ }
38
+ };
39
+ return {
40
+ req: { query } as unknown as express.Request,
41
+ res: res as unknown as express.Response,
42
+ getBody: () => body
43
+ };
44
+ }
45
+
46
+ describe('OAuthCallbackHandler escapeHtml', () => {
47
+ it('neutralizes <script>, quotes, and ampersands', () => {
48
+ const renderer = makeRenderer();
49
+ const escaped = renderer.escapeHtml(`<script>alert("x")&'y'</script>`);
50
+ expect(escaped).toBe('&lt;script&gt;alert(&quot;x&quot;)&amp;&#39;y&#39;&lt;/script&gt;');
51
+ expect(escaped).not.toContain('<script>');
52
+ });
53
+
54
+ it('escapes & first so no double-encoding occurs', () => {
55
+ const renderer = makeRenderer();
56
+ expect(renderer.escapeHtml('&lt;')).toBe('&amp;lt;');
57
+ });
58
+ });
59
+
60
+ describe('OAuthCallbackHandler reflected-XSS protection', () => {
61
+ it('escapes a malicious connectionId on the success page', () => {
62
+ const renderer = makeRenderer();
63
+ const { req, res, getBody } = makeReqRes({ connectionId: '<script>alert(1)</script>' });
64
+ renderer.handleSuccessPage(req, res);
65
+ const body = getBody();
66
+ expect(body).not.toContain('<script>alert(1)</script>');
67
+ expect(body).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
68
+ });
69
+
70
+ it('escapes malicious error code and description on the error page', () => {
71
+ const renderer = makeRenderer();
72
+ const { req, res, getBody } = makeReqRes({
73
+ error: '"><img src=x onerror=alert(1)>',
74
+ error_description: `</div><script>evil()</script>`
75
+ });
76
+ renderer.handleErrorPage(req, res);
77
+ const body = getBody();
78
+ expect(body).not.toContain('<img src=x onerror=alert(1)>');
79
+ expect(body).not.toContain('<script>evil()</script>');
80
+ expect(body).toContain('&lt;img src=x onerror=alert(1)&gt;');
81
+ expect(body).toContain('&lt;script&gt;evil()&lt;/script&gt;');
82
+ });
83
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import type { IncomingMessage } from 'node:http';
3
3
  import type { Duplex } from 'node:stream';
4
4
  import { RealtimeProxyRegistry, REALTIME_PROXY_PATH } from '@memberjunction/ai';
@@ -68,3 +68,55 @@ describe('RealtimeProxyServer.TryHandleUpgrade', () => {
68
68
  expect(fake.Destroyed).toBe(true); // already consumed → treated as invalid
69
69
  });
70
70
  });
71
+
72
+ describe('C3: Origin allowlist (MJ_REALTIME_PROXY_ALLOWED_ORIGINS)', () => {
73
+ const ENV_KEY = 'MJ_REALTIME_PROXY_ALLOWED_ORIGINS';
74
+ let saved: string | undefined;
75
+ let proxy: RealtimeProxyServer;
76
+
77
+ beforeEach(() => {
78
+ proxy = RealtimeProxyServer.Instance;
79
+ saved = process.env[ENV_KEY];
80
+ });
81
+ afterEach(() => {
82
+ if (saved === undefined) delete process.env[ENV_KEY];
83
+ else process.env[ENV_KEY] = saved;
84
+ });
85
+
86
+ function reqWithOrigin(url: string, origin?: string): IncomingMessage {
87
+ return { url, headers: origin ? { origin } : {} } as unknown as IncomingMessage;
88
+ }
89
+
90
+ it('no allowlist configured → prior behavior (origin irrelevant, ticket governs)', () => {
91
+ delete process.env[ENV_KEY];
92
+ const fake = new FakeSocket();
93
+ const owned = proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`, 'https://evil.example'), sock(fake), HEAD);
94
+ expect(owned).toBe(true);
95
+ expect(fake.Written.join('')).toContain('401'); // fell through to the ticket check
96
+ });
97
+
98
+ it('rejects a FOREIGN browser origin with 403 BEFORE consuming the ticket', () => {
99
+ process.env[ENV_KEY] = 'https://app.example.com, https://portal.example.com';
100
+ const ticket = RealtimeProxyRegistry.Instance.Issue({ UpstreamUrl: 'ws://internal:8000/v1/realtime', TTLSeconds: 60 });
101
+ const fake = new FakeSocket();
102
+ proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=${ticket.ID}`, 'https://evil.example'), sock(fake), HEAD);
103
+ expect(fake.Written.join('')).toContain('403');
104
+ // The ticket SURVIVED the rejected foreign attempt — the legitimate page can still connect.
105
+ expect(RealtimeProxyRegistry.Instance.Consume(ticket.ID)).not.toBeNull();
106
+ });
107
+
108
+ it('accepts an allowlisted origin (normalization: case + trailing slash)', () => {
109
+ process.env[ENV_KEY] = 'https://App.Example.com/';
110
+ const fake = new FakeSocket();
111
+ // Unknown ticket → passes the origin gate, fails the ticket check with 401 (NOT 403).
112
+ proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`, 'https://app.example.com'), sock(fake), HEAD);
113
+ expect(fake.Written.join('')).toContain('401');
114
+ });
115
+
116
+ it('an upgrade WITHOUT an Origin header (non-browser client) passes the gate', () => {
117
+ process.env[ENV_KEY] = 'https://app.example.com';
118
+ const fake = new FakeSocket();
119
+ proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`), sock(fake), HEAD);
120
+ expect(fake.Written.join('')).toContain('401'); // ticket check, not the origin gate
121
+ });
122
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // WidgetSessionService is the imperative shell for public widget sessions — importing it pulls in
4
+ // config.ts (which eagerly validates DB env vars at module load) plus the full magic-link /
5
+ // returning-visitor / communication stacks. This suite only exercises the `ResolvePerInstanceRateLimit`
6
+ // cache-bounding behavior, so stub those transitive deps out (mirrors the pattern already established
7
+ // in `magicLinkService.provisionLog.test.ts` for testing another imperative-shell class).
8
+ vi.mock('../config.js', () => ({
9
+ configInfo: {},
10
+ }));
11
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
12
+ UserCache: {
13
+ Instance: { Users: [], UserByName: () => undefined, GetSystemUser: () => undefined },
14
+ Users: [],
15
+ },
16
+ }));
17
+ vi.mock('../auth/magicLink/MagicLinkService.js', () => ({
18
+ MagicLinkService: class {},
19
+ }));
20
+ vi.mock('../agentSessions/ReturningVisitorRecap.js', () => ({
21
+ writeReturningVisitorRecap: async () => undefined,
22
+ }));
23
+ vi.mock('../realtimeWidget/visitorIdentity.js', () => ({
24
+ resolveIdentityByEmail: async () => null,
25
+ mergeVisitorIdentity: async () => 0,
26
+ forgetVisitor: async () => ({ notesArchived: 0, conversationsCleared: 0 }),
27
+ }));
28
+
29
+ import { WidgetSessionService } from '../realtimeWidget/WidgetSessionService.js';
30
+ import type { WidgetConfig } from '../config.js';
31
+
32
+ /**
33
+ * Minimal `WidgetConfig` matching the zod schema's post-parse (defaulted) shape —
34
+ * enough for `ResolvePerInstanceRateLimit`, which never dereferences most of these.
35
+ */
36
+ function makeConfig(overrides: Partial<WidgetConfig> = {}): WidgetConfig {
37
+ return {
38
+ enabled: true,
39
+ signingReuse: 'magic-link',
40
+ audience: 'mj-magic-link',
41
+ anonymousEmail: 'anonymous@magic-link.local',
42
+ defaultSessionTtlMinutes: 15,
43
+ defaultRateLimitPerMinute: 30,
44
+ rateLimitWindowMs: 60_000,
45
+ voiceDefaultMaxSessionMinutes: 10,
46
+ hostPublicKeys: {},
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ describe('WidgetSessionService — ResolvePerInstanceRateLimit', () => {
52
+ it('returns the deployment-wide default for an empty widget key without touching the cache', async () => {
53
+ const service = new WidgetSessionService('https://mj.example.com', makeConfig({ defaultRateLimitPerMinute: 42 }));
54
+ await expect(service.ResolvePerInstanceRateLimit('')).resolves.toBe(42);
55
+ expect(service.RateLimitCacheSize).toBe(0);
56
+ });
57
+
58
+ it('falls back to the default (and still caches) when the widget key cannot be resolved (no DB in unit tests)', async () => {
59
+ const service = new WidgetSessionService('https://mj.example.com', makeConfig({ defaultRateLimitPerMinute: 30 }));
60
+ await expect(service.ResolvePerInstanceRateLimit('pk_live_unknown')).resolves.toBe(30);
61
+ expect(service.RateLimitCacheSize).toBe(1);
62
+ });
63
+
64
+ it('caches a resolved limit per key instead of re-resolving on every call', async () => {
65
+ const service = new WidgetSessionService('https://mj.example.com', makeConfig());
66
+ await service.ResolvePerInstanceRateLimit('pk_live_same_key');
67
+ await service.ResolvePerInstanceRateLimit('pk_live_same_key');
68
+ await service.ResolvePerInstanceRateLimit('pk_live_same_key');
69
+ expect(service.RateLimitCacheSize).toBe(1);
70
+ });
71
+
72
+ it(
73
+ 'never grows the rate-limit cache past its configured bound, even under an unbounded stream of ' +
74
+ 'distinct attacker-supplied widget keys (Memory Leak Audit Round 8, Critical finding — the cache ' +
75
+ 'key is unauthenticated request input on the public /widget/session endpoint)',
76
+ async () => {
77
+ const service = new WidgetSessionService('https://mj.example.com', makeConfig());
78
+ // Far more distinct keys than any real deployment's widget-instance count, and comfortably
79
+ // more than the cache's configured max size — simulates an attacker sending a fresh garbage
80
+ // widgetKey on every request.
81
+ const distinctKeyCount = 12_000;
82
+ for (let i = 0; i < distinctKeyCount; i++) {
83
+ await service.ResolvePerInstanceRateLimit(`pk_live_attacker_${i}`);
84
+ }
85
+ expect(service.RateLimitCacheSize).toBeLessThan(distinctKeyCount);
86
+ expect(service.RateLimitCacheSize).toBeLessThanOrEqual(10_000);
87
+ },
88
+ );
89
+
90
+ it('trims whitespace before using the key to look up / populate the cache', async () => {
91
+ const service = new WidgetSessionService('https://mj.example.com', makeConfig());
92
+ await service.ResolvePerInstanceRateLimit(' pk_live_padded ');
93
+ await service.ResolvePerInstanceRateLimit('pk_live_padded');
94
+ expect(service.RateLimitCacheSize).toBe(1);
95
+ });
96
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { exactTotalFromPage, resolveAdhocTotalRowCount } from '../resolvers/adhoc-query-helpers';
3
+
4
+ describe('exactTotalFromPage', () => {
5
+ it('returns the row count when not paging (maxRows null) — the query was uncapped', () => {
6
+ expect(exactTotalFromPage(0, 47, null)).toBe(47);
7
+ expect(exactTotalFromPage(0, 0, null)).toBe(0);
8
+ });
9
+
10
+ it('returns startRow + pageLength for a SHORT page (fewer rows than the page size)', () => {
11
+ expect(exactTotalFromPage(0, 47, 100)).toBe(47); // page 1, short → exact total
12
+ expect(exactTotalFromPage(200, 30, 100)).toBe(230); // page 3, short last page
13
+ expect(exactTotalFromPage(100, 0, 100)).toBe(100); // empty page past the end
14
+ });
15
+
16
+ it('returns null for a FULL page — a COUNT is required because more rows may exist', () => {
17
+ expect(exactTotalFromPage(0, 100, 100)).toBeNull();
18
+ expect(exactTotalFromPage(500, 100, 100)).toBeNull();
19
+ });
20
+
21
+ it('treats an over-full page defensively as "needs count" (should not occur with OFFSET/FETCH)', () => {
22
+ expect(exactTotalFromPage(0, 150, 100)).toBeNull();
23
+ });
24
+ });
25
+
26
+ describe('resolveAdhocTotalRowCount', () => {
27
+ it('reads TotalRowCount from the count recordset', () => {
28
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: 2087 }], 100)).toBe(2087);
29
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: 0 }], 100)).toBe(0);
30
+ });
31
+
32
+ it('floors a fractional/stringified count', () => {
33
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: 42.9 }], 0)).toBe(42);
34
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: '2087' as unknown as number }], 0)).toBe(2087);
35
+ });
36
+
37
+ it('falls back when the count is absent, empty, null, or malformed', () => {
38
+ expect(resolveAdhocTotalRowCount(null, 100)).toBe(100);
39
+ expect(resolveAdhocTotalRowCount(undefined, 100)).toBe(100);
40
+ expect(resolveAdhocTotalRowCount([], 100)).toBe(100);
41
+ expect(resolveAdhocTotalRowCount([{}], 100)).toBe(100);
42
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: null }], 100)).toBe(100);
43
+ });
44
+
45
+ it('falls back on non-finite or negative counts (never reports a misleading total)', () => {
46
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: Number.NaN }], 100)).toBe(100);
47
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: -5 }], 100)).toBe(100);
48
+ expect(resolveAdhocTotalRowCount([{ TotalRowCount: Infinity }], 100)).toBe(100);
49
+ });
50
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // MagicLinkService is the imperative shell — importing it pulls the full
4
+ // data-provider and communication stacks in at module load. Stub those out;
5
+ // this suite only exercises provisioning's console-log behavior, so
6
+ // @memberjunction/core (the logging implementation) stays real.
7
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
8
+ UserCache: { Instance: { Users: [] } },
9
+ }));
10
+ vi.mock('@memberjunction/communication-engine', () => ({
11
+ CommunicationEngine: { Instance: {} },
12
+ }));
13
+ vi.mock('@memberjunction/communication-types', () => ({
14
+ Message: class {},
15
+ }));
16
+ vi.mock('../config.js', () => ({
17
+ configInfo: {},
18
+ }));
19
+
20
+ import { Metadata, type IMetadataProvider, type RoleInfo, type UserInfo } from '@memberjunction/core';
21
+ import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
22
+ import type { MagicLinkConfig } from '../config.js';
23
+ import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
24
+
25
+ /** Minimal BaseEntity stand-in: NewRecord/Save/GetAll plus writable fields. */
26
+ interface MockEntity {
27
+ NewRecord: () => void;
28
+ Save: () => Promise<boolean>;
29
+ GetAll: () => Record<string, unknown>;
30
+ LatestResult?: { CompleteMessage: string };
31
+ [field: string]: unknown;
32
+ }
33
+
34
+ function makeMockEntity(id: string): MockEntity {
35
+ const entity: MockEntity = {
36
+ NewRecord: () => {
37
+ entity.ID = id;
38
+ },
39
+ Save: async () => true,
40
+ GetAll: () => ({
41
+ ID: entity.ID,
42
+ Name: entity.Name,
43
+ Email: entity.Email,
44
+ FirstName: entity.FirstName,
45
+ LastName: entity.LastName,
46
+ }),
47
+ };
48
+ return entity;
49
+ }
50
+
51
+ describe('MagicLinkService provisioning log policy', () => {
52
+ const originalVerbose = process.env.MJ_VERBOSE;
53
+ let service: MagicLinkService;
54
+ let invite: MJMagicLinkInviteEntity;
55
+ let role: RoleInfo;
56
+ let contextUser: UserInfo;
57
+
58
+ beforeEach(() => {
59
+ const provider = {
60
+ BeginTransaction: async () => undefined,
61
+ CommitTransaction: async () => undefined,
62
+ RollbackTransaction: async () => undefined,
63
+ GetEntityObject: async (entityName: string) => makeMockEntity(`mock-${entityName}`),
64
+ };
65
+ Metadata.Provider = provider as unknown as IMetadataProvider;
66
+
67
+ service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
68
+ invite = {
69
+ Email: 'guest@example.com',
70
+ RoleID: 'role-1',
71
+ ApplicationID: 'app-1',
72
+ } as unknown as MJMagicLinkInviteEntity;
73
+ role = { ID: 'role-1', Name: 'External Guest' } as RoleInfo;
74
+ contextUser = { ID: 'ctx-user' } as UserInfo;
75
+ });
76
+
77
+ afterEach(() => {
78
+ Metadata.Provider = null as unknown as IMetadataProvider;
79
+ if (originalVerbose === undefined) {
80
+ delete process.env.MJ_VERBOSE;
81
+ } else {
82
+ process.env.MJ_VERBOSE = originalVerbose;
83
+ }
84
+ });
85
+
86
+ async function provisionAndCollectLogs(): Promise<{ success: boolean; provisionLines: string[] }> {
87
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
88
+ const result = await service['createScopedUser'](invite, role, contextUser);
89
+ const provisionLines = logSpy.mock.calls
90
+ .map((call) => String(call[0]))
91
+ .filter((line) => line.includes('[MagicLink] Provisioned'));
92
+ return { success: result.success, provisionLines };
93
+ }
94
+
95
+ // Deliberate policy (do not verbose-gate this like the startup notices in 2ddb865e40):
96
+ // magic-link redemption provisioning a new external account is a security-relevant
97
+ // event and must stay visible in the DEFAULT server log, so operators see the vector
98
+ // being exercised without needing MJ_VERBOSE.
99
+ it('emits the provisioned-user notice on the default (non-verbose) server log', async () => {
100
+ delete process.env.MJ_VERBOSE;
101
+ const { success, provisionLines } = await provisionAndCollectLogs();
102
+ expect(success).toBe(true);
103
+ expect(provisionLines).toHaveLength(1);
104
+ expect(provisionLines[0]).toContain("guest@example.com with role 'External Guest'");
105
+ });
106
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { IsGraphQLWsPath } from '../telephony/media-upgrade-router.js';
3
+
4
+ describe('IsGraphQLWsPath', () => {
5
+ describe('bare-root default (GRAPHQL_ROOT_PATH unset — the stock-install case)', () => {
6
+ it('accepts an exact match on the bare root', () => {
7
+ expect(IsGraphQLWsPath('/', '/')).toBe(true);
8
+ });
9
+
10
+ it('accepts the conventional /graphql suffix as an alias of the bare root', () => {
11
+ // This is the fix: MJExplorer's shipped environment.ts hardcodes
12
+ // GRAPHQL_WS_URI: 'ws://<host>/graphql', which never matched the server's
13
+ // bare-root default before this alias existed — every subscription upgrade
14
+ // was silently rejected (400) on a stock install with no .env overrides.
15
+ expect(IsGraphQLWsPath('/graphql', '/')).toBe(true);
16
+ });
17
+
18
+ it('rejects an unrelated path', () => {
19
+ expect(IsGraphQLWsPath('/telephony/twilio/media', '/')).toBe(false);
20
+ });
21
+
22
+ it('rejects a path that merely starts with /graphql (no accidental prefix match)', () => {
23
+ expect(IsGraphQLWsPath('/graphql-admin', '/')).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe('non-default configured root (custom GRAPHQL_ROOT_PATH — unchanged behavior)', () => {
28
+ it('accepts an exact match on the configured root', () => {
29
+ expect(IsGraphQLWsPath('/api/graphql', '/api/graphql')).toBe(true);
30
+ });
31
+
32
+ it('does NOT apply the /graphql alias when the configured root is not the bare root', () => {
33
+ // The alias is scoped to the bare-root default only — a deployment that has
34
+ // deliberately set a custom root keeps requiring an exact match, so this
35
+ // fix can't silently widen what a custom-configured server accepts.
36
+ expect(IsGraphQLWsPath('/graphql', '/api/graphql')).toBe(false);
37
+ });
38
+
39
+ it('rejects a mismatched custom path', () => {
40
+ expect(IsGraphQLWsPath('/', '/api/graphql')).toBe(false);
41
+ });
42
+ });
43
+ });