@memberjunction/server 6.1.3 → 6.1.5
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.
- package/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +49 -35
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +118 -3
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/DeleteOptionsInput.d.ts +8 -1
- package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
- package/dist/generic/DeleteOptionsInput.js +1 -1
- package/dist/generic/DeleteOptionsInput.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +4 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +10 -3
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -47
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +54 -174
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/services/TaskGraphActionRunner.d.ts +13 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +16 -15
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +224 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/magicLink/MagicLinkService.ts +55 -36
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/config.ts +23 -0
- package/src/context.ts +11 -24
- package/src/generic/CacheInvalidationResolver.ts +139 -3
- package/src/generic/DeleteOptionsInput.ts +9 -2
- package/src/generic/ResolverBase.ts +11 -3
- package/src/index.ts +55 -48
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +64 -194
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/services/TaskGraphActionRunner.ts +16 -15
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bring it in automatically — this line MUST precede any import that pulls in the resolver file.
|
|
4
4
|
import 'reflect-metadata';
|
|
5
5
|
|
|
6
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
7
|
|
|
8
8
|
// --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) plus the
|
|
9
9
|
// provider-scoped AIEngineBase whose cached metadata serves the resolver's pairing-row
|
|
@@ -210,9 +210,23 @@ interface FakeSession {
|
|
|
210
210
|
NewRecord: () => void;
|
|
211
211
|
Save: () => Promise<boolean>;
|
|
212
212
|
Load: (id: string) => Promise<boolean>;
|
|
213
|
-
LatestResult?: { CompleteMessage?: string };
|
|
213
|
+
LatestResult?: { CompleteMessage?: string; Success?: boolean };
|
|
214
|
+
/** Only set on the fakes exercising `describeSaveFailure`'s "no failure detail recorded" branches. */
|
|
215
|
+
ResultHistory?: unknown[];
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A `console.error` spy shared by the save-failure-log tests (#4791) below. Assigned with
|
|
220
|
+
* `vi.spyOn(console, 'error').mockImplementation(() => undefined)` inside each test that needs it
|
|
221
|
+
* — restored here so no test leaks a mocked `console.error` into the ones after it.
|
|
222
|
+
*/
|
|
223
|
+
let errSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
errSpy?.mockRestore();
|
|
227
|
+
errSpy = undefined;
|
|
228
|
+
});
|
|
229
|
+
|
|
216
230
|
function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
|
|
217
231
|
return {
|
|
218
232
|
ID: 'session-1',
|
|
@@ -848,6 +862,28 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
|
|
|
848
862
|
resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
|
|
849
863
|
).rejects.toThrow(/no target agent configured/i);
|
|
850
864
|
});
|
|
865
|
+
|
|
866
|
+
it('logs the session id and "no failure detail recorded" when updatePendingFeedbackRunID\'s save is refused silently (#4791)', async () => {
|
|
867
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
868
|
+
const session = makeSessionEntity({
|
|
869
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1' }),
|
|
870
|
+
Save: vi.fn(async () => false),
|
|
871
|
+
LatestResult: undefined,
|
|
872
|
+
ResultHistory: [],
|
|
873
|
+
});
|
|
874
|
+
currentProvider = makeProvider(() => session);
|
|
875
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
|
|
876
|
+
const resolver = makeResolver();
|
|
877
|
+
|
|
878
|
+
await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
|
|
879
|
+
|
|
880
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
881
|
+
const failureLine = loggedLines.find((line) => line.includes('updatePendingFeedbackRunID'));
|
|
882
|
+
expect(failureLine).toBeDefined();
|
|
883
|
+
expect(failureLine).toContain('session-1');
|
|
884
|
+
expect(failureLine).toContain('no failure detail recorded');
|
|
885
|
+
expect(failureLine).not.toContain('unknown error');
|
|
886
|
+
});
|
|
851
887
|
});
|
|
852
888
|
|
|
853
889
|
describe('RealtimeClientSessionResolver.RelayRealtimeTranscript', () => {
|
|
@@ -1690,6 +1726,7 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1690
1726
|
});
|
|
1691
1727
|
|
|
1692
1728
|
it('returns a structured failure when the artifact header save fails (no version attempted)', async () => {
|
|
1729
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1693
1730
|
const failingArtifact = makeSessionEntity({
|
|
1694
1731
|
ID: 'artifact-fail',
|
|
1695
1732
|
Save: vi.fn(async () => false),
|
|
@@ -1706,10 +1743,14 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1706
1743
|
|
|
1707
1744
|
expect(result.Success).toBe(false);
|
|
1708
1745
|
expect(result.ErrorMessage).toMatch(/artifact save failed/i);
|
|
1746
|
+
expect(result.ErrorMessage).toContain('session session-1');
|
|
1747
|
+
expect(result.ErrorMessage).toContain('db down');
|
|
1748
|
+
expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
|
|
1709
1749
|
expect(version.Save).not.toHaveBeenCalled();
|
|
1710
1750
|
});
|
|
1711
1751
|
|
|
1712
1752
|
it('returns a structured failure (carrying the orphaned ArtifactID) when the version save fails', async () => {
|
|
1753
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1713
1754
|
const failingVersion = makeSessionEntity({
|
|
1714
1755
|
ID: 'version-fail',
|
|
1715
1756
|
Save: vi.fn(async () => false),
|
|
@@ -1728,6 +1769,10 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1728
1769
|
expect(result.Success).toBe(false);
|
|
1729
1770
|
expect(result.ArtifactID).toBe('artifact-1');
|
|
1730
1771
|
expect(result.ArtifactVersionID).toBeUndefined();
|
|
1772
|
+
expect(result.ErrorMessage).toMatch(/artifact version save failed/i);
|
|
1773
|
+
expect(result.ErrorMessage).toContain('session session-1');
|
|
1774
|
+
expect(result.ErrorMessage).toContain('too big');
|
|
1775
|
+
expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
|
|
1731
1776
|
expect(junction.Save).not.toHaveBeenCalled();
|
|
1732
1777
|
});
|
|
1733
1778
|
|
|
@@ -2880,6 +2925,44 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
2880
2925
|
expect(executeRelayedToolMock.mock.calls[0][1]).toBe(ANON_USER);
|
|
2881
2926
|
});
|
|
2882
2927
|
|
|
2928
|
+
it('writes the hidden direct-action tool turn (ExecuteRealtimeSessionTool) as the CALLER, not the system user (#4791)', async () => {
|
|
2929
|
+
const detail = makeSessionEntity({ ID: 'detail-1' });
|
|
2930
|
+
currentProvider = {
|
|
2931
|
+
GetEntityObject: vi.fn(async (name: string) =>
|
|
2932
|
+
name === 'MJ: Conversation Details' ? detail : makeSessionEntity({ UserID: 'anon-1' }),
|
|
2933
|
+
),
|
|
2934
|
+
};
|
|
2935
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
|
|
2936
|
+
const resolver = makeAnonResolver();
|
|
2937
|
+
|
|
2938
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2939
|
+
'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
|
|
2940
|
+
);
|
|
2941
|
+
|
|
2942
|
+
// The dispatch itself still runs elevated (SYSTEM_USER)…
|
|
2943
|
+
expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
|
|
2944
|
+
// …but the hidden Conversation Detail turn is written as the CALLER, never the system user.
|
|
2945
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
2946
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
2947
|
+
expect(detail.Save).toHaveBeenCalled();
|
|
2948
|
+
expect(detail.UserID).toBe('anon-1');
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('writes the direct-action tool turn as the caller unchanged for a normal authenticated user', async () => {
|
|
2952
|
+
const detail = makeSessionEntity({ ID: 'detail-2' });
|
|
2953
|
+
currentProvider = {
|
|
2954
|
+
GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: Conversation Details' ? detail : makeSessionEntity())),
|
|
2955
|
+
};
|
|
2956
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
|
|
2957
|
+
const resolver = makeResolver();
|
|
2958
|
+
|
|
2959
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2960
|
+
'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
|
|
2961
|
+
);
|
|
2962
|
+
|
|
2963
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', USER);
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2883
2966
|
it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
|
|
2884
2967
|
const session = makeSessionEntity({
|
|
2885
2968
|
UserID: 'anon-1',
|
|
@@ -2942,6 +3025,32 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
2942
3025
|
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
|
|
2943
3026
|
});
|
|
2944
3027
|
|
|
3028
|
+
it('writes the hidden co-agent tool turn (RelayRealtimeToolTurn) as the CALLER (#4791)', async () => {
|
|
3029
|
+
const session = makeSessionEntity({
|
|
3030
|
+
UserID: 'anon-1',
|
|
3031
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
|
|
3032
|
+
});
|
|
3033
|
+
const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
|
|
3034
|
+
const detail = makeSessionEntity({ ID: 'detail-3' });
|
|
3035
|
+
currentProvider = {
|
|
3036
|
+
GetEntityObject: vi.fn(async (name: string) => {
|
|
3037
|
+
if (name === 'MJ: AI Prompt Runs') return promptRun;
|
|
3038
|
+
if (name === 'MJ: Conversation Details') return detail;
|
|
3039
|
+
return session;
|
|
3040
|
+
}),
|
|
3041
|
+
};
|
|
3042
|
+
const resolver = makeAnonResolver();
|
|
3043
|
+
|
|
3044
|
+
const ok = await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{"url":"x"}');
|
|
3045
|
+
|
|
3046
|
+
expect(ok).toBe(true);
|
|
3047
|
+
// The co-agent run mirror stays SYSTEM…
|
|
3048
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
|
|
3049
|
+
// …but the hidden Conversation Detail tool-turn is written as the CALLER, never SYSTEM.
|
|
3050
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
3051
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
3052
|
+
});
|
|
3053
|
+
|
|
2945
3054
|
it('prepares the session with observability under the SYSTEM user while UserID stays the visitor', async () => {
|
|
2946
3055
|
hasPermissionMock.mockResolvedValue(true);
|
|
2947
3056
|
currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
|
|
@@ -3003,6 +3112,10 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
3003
3112
|
// The junction write (an entity the anon role does NOT hold) runs as the SYSTEM user.
|
|
3004
3113
|
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Detail Artifacts', SYSTEM_USER);
|
|
3005
3114
|
expect(junctions).toHaveLength(1);
|
|
3115
|
+
// The hidden anchor `Conversation Detail` is written as the CALLER (#4791) — like every
|
|
3116
|
+
// other `MJ: Conversation Details` write, the System user is refused as not the owner.
|
|
3117
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
3118
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
3006
3119
|
// The hidden anchor is attributed to the SESSION owner, not the elevated principal.
|
|
3007
3120
|
expect(anchors).toHaveLength(1);
|
|
3008
3121
|
expect(anchors[0].UserID).toBe('anon-1');
|
|
@@ -3102,6 +3215,107 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
3102
3215
|
expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(USER);
|
|
3103
3216
|
expect(getSystemUserMock).not.toHaveBeenCalled();
|
|
3104
3217
|
});
|
|
3218
|
+
|
|
3219
|
+
/** Provider routing a hidden-tool-turn RelayRealtimeToolTurn call: session, prompt run, detail. */
|
|
3220
|
+
function makeToolTurnProvider(detail: FakeSession): unknown {
|
|
3221
|
+
const session = makeSessionEntity({
|
|
3222
|
+
UserID: 'anon-1',
|
|
3223
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
|
|
3224
|
+
});
|
|
3225
|
+
const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
|
|
3226
|
+
return {
|
|
3227
|
+
GetEntityObject: vi.fn(async (name: string) => {
|
|
3228
|
+
if (name === 'MJ: AI Prompt Runs') return promptRun;
|
|
3229
|
+
if (name === 'MJ: Conversation Details') return detail;
|
|
3230
|
+
return session;
|
|
3231
|
+
}),
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
it('logs the session id, write user id and "no failure detail recorded" when a hidden tool turn save is refused without a LatestResult (#4791)', async () => {
|
|
3236
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3237
|
+
const detail = makeSessionEntity({
|
|
3238
|
+
ID: 'detail-1',
|
|
3239
|
+
Save: vi.fn(async () => false),
|
|
3240
|
+
LatestResult: undefined,
|
|
3241
|
+
ResultHistory: [],
|
|
3242
|
+
});
|
|
3243
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3244
|
+
const resolver = makeAnonResolver();
|
|
3245
|
+
|
|
3246
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3247
|
+
|
|
3248
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3249
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3250
|
+
expect(failureLine).toBeDefined();
|
|
3251
|
+
// Exact substrings: 'anon-1' alone is the caller, session.UserID AND detail.UserID at once,
|
|
3252
|
+
// so only the labelled form proves the WRITE user is what got logged.
|
|
3253
|
+
expect(failureLine).toContain('session session-1');
|
|
3254
|
+
expect(failureLine).toContain('write user anon-1');
|
|
3255
|
+
expect(failureLine).toContain('no failure detail recorded (LatestResult null; ResultHistory length 0)');
|
|
3256
|
+
expect(failureLine).toContain('the write was refused before reaching the provider');
|
|
3257
|
+
expect(failureLine).not.toContain('unknown error');
|
|
3258
|
+
});
|
|
3259
|
+
|
|
3260
|
+
it('does not claim WHERE the write was refused when a result was registered with an empty message (#4791)', async () => {
|
|
3261
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3262
|
+
const detail = makeSessionEntity({
|
|
3263
|
+
ID: 'detail-1',
|
|
3264
|
+
Save: vi.fn(async () => false),
|
|
3265
|
+
LatestResult: { Success: false, CompleteMessage: ' ' },
|
|
3266
|
+
ResultHistory: [{}],
|
|
3267
|
+
});
|
|
3268
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3269
|
+
const resolver = makeAnonResolver();
|
|
3270
|
+
|
|
3271
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3272
|
+
|
|
3273
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3274
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3275
|
+
expect(failureLine).toBeDefined();
|
|
3276
|
+
expect(failureLine).toContain('no failure detail recorded (LatestResult empty; ResultHistory length 1)');
|
|
3277
|
+
expect(failureLine).not.toContain('refused before reaching the provider');
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
it('never logs a PRIOR SUCCESS entry\'s text as the failure reason when the refusal registered nothing (#4791)', async () => {
|
|
3281
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3282
|
+
const detail = makeSessionEntity({
|
|
3283
|
+
ID: 'detail-1',
|
|
3284
|
+
Save: vi.fn(async () => false),
|
|
3285
|
+
LatestResult: { Success: true, CompleteMessage: 'previous save ok' },
|
|
3286
|
+
ResultHistory: [{}, {}],
|
|
3287
|
+
});
|
|
3288
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3289
|
+
const resolver = makeAnonResolver();
|
|
3290
|
+
|
|
3291
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3292
|
+
|
|
3293
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3294
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3295
|
+
expect(failureLine).toBeDefined();
|
|
3296
|
+
expect(failureLine).toContain(
|
|
3297
|
+
'no failure detail recorded (latest result entry is a prior success; ResultHistory length 2)',
|
|
3298
|
+
);
|
|
3299
|
+
expect(failureLine).not.toContain('previous save ok');
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3302
|
+
it('logs the real CompleteMessage when present (#4791)', async () => {
|
|
3303
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3304
|
+
const detail = makeSessionEntity({
|
|
3305
|
+
ID: 'detail-1',
|
|
3306
|
+
Save: vi.fn(async () => false),
|
|
3307
|
+
LatestResult: { CompleteMessage: 'You do not have access to this conversation.' },
|
|
3308
|
+
});
|
|
3309
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3310
|
+
const resolver = makeAnonResolver();
|
|
3311
|
+
|
|
3312
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3313
|
+
|
|
3314
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3315
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3316
|
+
expect(failureLine).toBeDefined();
|
|
3317
|
+
expect(failureLine).toContain('You do not have access to this conversation.');
|
|
3318
|
+
});
|
|
3105
3319
|
});
|
|
3106
3320
|
|
|
3107
3321
|
describe('RealtimeClientSessionResolver — colleague authorization (allowedAgents)', () => {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end-in-miniature regression test for #4794.
|
|
3
|
+
*
|
|
4
|
+
* The bug: `BuildDurableDeferral` persisted a durable entity action's redacted params as a
|
|
5
|
+
* `LoggedParam[]` ARRAY (`RedactParamsToJSON`'s shape), while `TaskGraphActionRunner.buildParams`
|
|
6
|
+
* reads `Task.InputPayload` back as a name→value RECORD. Every released build with durable dispatch
|
|
7
|
+
* (v6.1.0 onward, including 6.1.4) hit the same real-world failure: `TaskGraphDispatcher`'s
|
|
8
|
+
* `mergedPayload` only merges plain objects, so it silently dropped the array before `buildParams`
|
|
9
|
+
* ever saw it, and the action ran with NONE of its declared inputs. `buildParams` itself has no such
|
|
10
|
+
* guard — `Object.assign(merged, arrayValue)` treats an array's indices as keys — so params named
|
|
11
|
+
* `'0'`, `'1'`, ... would only appear if the array reached it directly, bypassing the dispatcher's
|
|
12
|
+
* drop. That narrower path is exactly what this test exercises, by calling `RunActionForTask`
|
|
13
|
+
* on its own rather than through the dispatcher.
|
|
14
|
+
*
|
|
15
|
+
* This test proves the whole pipe, not just the writer or the reader in isolation: build the
|
|
16
|
+
* payload exactly as the deferral does (`RedactParamsToRecord`), push it through the same
|
|
17
|
+
* `JSON.stringify` / `JSON.parse` round trip `TaskGraphService` performs to persist and reload
|
|
18
|
+
* `Task.InputPayload`, then run it through the real `TaskGraphActionRunner` and assert the action
|
|
19
|
+
* receives its declared parameters BY NAME.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
22
|
+
|
|
23
|
+
vi.mock('@memberjunction/actions', () => ({
|
|
24
|
+
ActionEngineServer: {
|
|
25
|
+
Instance: {
|
|
26
|
+
Config: vi.fn(),
|
|
27
|
+
Actions: [{ ID: 'A1', Name: 'Log Activity' }],
|
|
28
|
+
RunAction: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { ActionEngineServer } from '@memberjunction/actions';
|
|
34
|
+
import { RedactParamsToRecord, ActionParam } from '@memberjunction/actions-base';
|
|
35
|
+
import type { MJActionExecutionLogEntity, MJActionParamEntity, MJEntityActionParamEntity } from '@memberjunction/core-entities';
|
|
36
|
+
import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
37
|
+
import type { TaskActionRunParams } from '@memberjunction/task-graph';
|
|
38
|
+
import { TaskGraphActionRunner } from '../services/TaskGraphActionRunner';
|
|
39
|
+
|
|
40
|
+
// ── Test fixture builders — same shape as packages/Actions/Base/src/__tests__/ParamRedaction.test.ts ──
|
|
41
|
+
|
|
42
|
+
function actionParam(name: string, value: unknown): ActionParam {
|
|
43
|
+
return { Name: name, Value: value, Type: 'Input' } as ActionParam;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function definition(id: string, name: string, logValue: boolean = true): MJActionParamEntity {
|
|
47
|
+
return { ID: id, Name: name, LogValue: logValue } as MJActionParamEntity;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function binding(actionParamID: string, valueType: string, logValue: boolean | null = null): MJEntityActionParamEntity {
|
|
51
|
+
return { ActionParamID: actionParamID, ValueType: valueType, LogValue: logValue } as MJEntityActionParamEntity;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The declared parameters for the durable entity action under test: three that log normally, and
|
|
55
|
+
// one ("InternalNotes") whose binding opts out of logging (rule 2 in ParamRedaction.ts).
|
|
56
|
+
const RUNTIME_PARAMS: ActionParam[] = [
|
|
57
|
+
actionParam('TypeCode', 'SystemEvent'),
|
|
58
|
+
actionParam('Title', 'Person created'),
|
|
59
|
+
actionParam('RecordID', 'REC-123'),
|
|
60
|
+
actionParam('InternalNotes', 'do not persist this'),
|
|
61
|
+
];
|
|
62
|
+
const ACTION_PARAMS: MJActionParamEntity[] = [
|
|
63
|
+
definition('p1', 'TypeCode'),
|
|
64
|
+
definition('p2', 'Title'),
|
|
65
|
+
definition('p3', 'RecordID'),
|
|
66
|
+
definition('p4', 'InternalNotes'),
|
|
67
|
+
];
|
|
68
|
+
const ENTITY_ACTION_PARAMS: MJEntityActionParamEntity[] = [binding('p4', 'Static', false)];
|
|
69
|
+
|
|
70
|
+
function baseRunParams(inputPayload: unknown): TaskActionRunParams {
|
|
71
|
+
return {
|
|
72
|
+
TaskID: 'T1',
|
|
73
|
+
ActionID: 'A1',
|
|
74
|
+
InputPayload: inputPayload,
|
|
75
|
+
DependencyOutputs: new Map(),
|
|
76
|
+
Provider: {} as IMetadataProvider,
|
|
77
|
+
ContextUser: {} as UserInfo,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('TaskGraphActionRunner — durable payload round-trips by param name (#4794)', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
// vitest's `restoreMocks: true` clears mock implementations before every test, including
|
|
84
|
+
// the ones assigned inside the `vi.mock` factory above — so the return value is (re)armed here.
|
|
85
|
+
vi.mocked(ActionEngineServer.Instance.Config).mockResolvedValue(undefined);
|
|
86
|
+
vi.mocked(ActionEngineServer.Instance.RunAction, { partial: true }).mockResolvedValue({
|
|
87
|
+
Success: true,
|
|
88
|
+
Params: [],
|
|
89
|
+
LogEntry: { ID: 'L1' } as MJActionExecutionLogEntity,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('delivers RedactParamsToRecord\'s output to the action by name, after a JSON round trip', async () => {
|
|
94
|
+
// Exactly what the deferral writes, then exactly what TaskGraphService's JSON column does to it.
|
|
95
|
+
const stored = JSON.parse(JSON.stringify(RedactParamsToRecord(RUNTIME_PARAMS, ACTION_PARAMS, ENTITY_ACTION_PARAMS)));
|
|
96
|
+
|
|
97
|
+
const result = await new TaskGraphActionRunner().RunActionForTask(baseRunParams(stored));
|
|
98
|
+
|
|
99
|
+
expect(result.Success).toBe(true);
|
|
100
|
+
expect(result.ActionLogID).toBe('L1');
|
|
101
|
+
|
|
102
|
+
const runActionCall = vi.mocked(ActionEngineServer.Instance.RunAction).mock.calls[0][0];
|
|
103
|
+
const sentParams = runActionCall.Params as ActionParam[];
|
|
104
|
+
|
|
105
|
+
// The three logged declared names arrive, order-insensitive, with their values intact.
|
|
106
|
+
expect(sentParams.map((p) => p.Name).sort()).toEqual(['RecordID', 'Title', 'TypeCode']);
|
|
107
|
+
expect(sentParams.find((p) => p.Name === 'TypeCode')?.Value).toBe('SystemEvent');
|
|
108
|
+
expect(sentParams.find((p) => p.Name === 'Title')?.Value).toBe('Person created');
|
|
109
|
+
expect(sentParams.find((p) => p.Name === 'RecordID')?.Value).toBe('REC-123');
|
|
110
|
+
|
|
111
|
+
// The binding-suppressed parameter never reaches the action — absent, not present-with-undefined.
|
|
112
|
+
expect(sentParams.some((p) => p.Name === 'InternalNotes')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not turn dependency outputs into params named by the upstream task ID', async () => {
|
|
116
|
+
// The dispatcher keys DependencyOutputs by the upstream task's ID and has ALREADY merged their
|
|
117
|
+
// values into InputPayload (TaskGraphDispatcher.mergedPayload). A runner that also spread the
|
|
118
|
+
// map handed the action one extra param per dependency, named by a GUID and holding that
|
|
119
|
+
// task's whole output — which then landed, unredacted, in ActionExecutionLog.Params.
|
|
120
|
+
const upstreamID = '20924BD7-5128-4150-8404-FB44A55AEA0F';
|
|
121
|
+
const params = baseRunParams({ EntityName: 'MJ: Entities', Record: { ID: 'R1', Name: 'MJ: Tags' } });
|
|
122
|
+
params.DependencyOutputs = new Map([[upstreamID, { Record: { ID: 'R1', Name: 'MJ: Tags' } }]]);
|
|
123
|
+
|
|
124
|
+
await new TaskGraphActionRunner().RunActionForTask(params);
|
|
125
|
+
|
|
126
|
+
const sentParams = vi.mocked(ActionEngineServer.Instance.RunAction).mock.calls[0][0].Params as ActionParam[];
|
|
127
|
+
expect(sentParams.map((p) => p.Name).sort()).toEqual(['EntityName', 'Record']);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ad-hoc query's timeout never exceeds the server's requestTimeout, so a caller cannot hold a
|
|
3
|
+
* read-only connection longer than any other request may.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout';
|
|
7
|
+
|
|
8
|
+
describe('ClampAdhocTimeoutSeconds', () => {
|
|
9
|
+
it('caps a longer request at requestTimeout', () => {
|
|
10
|
+
expect(ClampAdhocTimeoutSeconds(600, 30000)).toBe(30);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps a shorter request', () => {
|
|
14
|
+
expect(ClampAdhocTimeoutSeconds(5, 30000)).toBe(5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses 30 seconds when the caller names none, within the cap', () => {
|
|
18
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 30000)).toBe(30);
|
|
19
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 10000)).toBe(10);
|
|
20
|
+
expect(ClampAdhocTimeoutSeconds(0, 30000)).toBe(30);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves the request alone when the server sets no limit', () => {
|
|
24
|
+
expect(ClampAdhocTimeoutSeconds(600, 0)).toBe(600);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The gate lives beside the subscription it protects, and that module carries type-graphql
|
|
2
|
+
// decorators — so the metadata shim has to be in place before it is imported.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
ConfigureRecordDataBroadcast,
|
|
7
|
+
MayBroadcastRecordData,
|
|
8
|
+
} from '../generic/CacheInvalidationResolver.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The cache-invalidation subscription is delivered to every connected client with no per-user
|
|
12
|
+
* filter, so anything `MayBroadcastRecordData` lets through is readable by every signed-in session
|
|
13
|
+
* regardless of row-level security or any tenant scoping a consumer applies on the read path.
|
|
14
|
+
*
|
|
15
|
+
* The important case is the DEFAULT: an untouched deployment must broadcast no row data at all,
|
|
16
|
+
* because that is the configuration everyone upgrades into.
|
|
17
|
+
*/
|
|
18
|
+
describe('MayBroadcastRecordData', () => {
|
|
19
|
+
afterEach(() => ConfigureRecordDataBroadcast([]));
|
|
20
|
+
|
|
21
|
+
it('broadcasts nothing before anything has configured it', () => {
|
|
22
|
+
// No ConfigureRecordDataBroadcast call at all — the state a process starts in, and the state it
|
|
23
|
+
// stays in if bootstrap ever stops calling it. Failing closed there is the whole point.
|
|
24
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
25
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('broadcasts nothing when the deployment has not opted in', () => {
|
|
29
|
+
ConfigureRecordDataBroadcast([]);
|
|
30
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('broadcasts nothing when the config value is absent', () => {
|
|
34
|
+
ConfigureRecordDataBroadcast(undefined);
|
|
35
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('allows exactly the entities named, and no others', () => {
|
|
39
|
+
ConfigureRecordDataBroadcast(['AI Models']);
|
|
40
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(true);
|
|
41
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
42
|
+
expect(MayBroadcastRecordData('AI Model Vendors')).toBe(false);
|
|
43
|
+
// The allowlisted name as a SUBSTRING of another entity must not carry it in. Written this way
|
|
44
|
+
// deliberately: 'AI Model Vendors' above does not contain 'AI Models', so it passes against a
|
|
45
|
+
// loose `includes` match too and proves nothing — a substring mutation survived it.
|
|
46
|
+
expect(MayBroadcastRecordData('Archived AI Models 2024')).toBe(false);
|
|
47
|
+
// ...and the reverse direction, where the entity name is a substring of the allowlisted one.
|
|
48
|
+
expect(MayBroadcastRecordData('AI Model')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('matches entity names case- and whitespace-insensitively', () => {
|
|
52
|
+
ConfigureRecordDataBroadcast([' ai models ']);
|
|
53
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("restores the previous broadcast-everything behaviour only on an explicit '*'", () => {
|
|
57
|
+
ConfigureRecordDataBroadcast(['*']);
|
|
58
|
+
expect(MayBroadcastRecordData('Users')).toBe(true);
|
|
59
|
+
expect(MayBroadcastRecordData('Organization Person Roles')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Importing this module must not drag server config loading in with it.
|
|
65
|
+
*
|
|
66
|
+
* The first version of this change read `configInfo` at module scope. `config.ts` runs
|
|
67
|
+
* `loadConfig()` on import, so every test that touched the resolver — directly or through
|
|
68
|
+
* ResolverBase — died at import with "Configuration validation failed". CI caught it; this keeps
|
|
69
|
+
* it caught.
|
|
70
|
+
*/
|
|
71
|
+
describe('CacheInvalidationResolver module surface', () => {
|
|
72
|
+
it('imports without any server configuration present', async () => {
|
|
73
|
+
await expect(import('../generic/CacheInvalidationResolver.js')).resolves.toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The allowlist is normalised where it enters, not where it is compared.
|
|
79
|
+
*
|
|
80
|
+
* Raised in review: the wildcard was matched raw while entity names were trimmed and lowercased,
|
|
81
|
+
* so a hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
|
|
82
|
+
* reads as "broadcast everything" while the gate stayed fully closed.
|
|
83
|
+
*/
|
|
84
|
+
describe('allowlist normalisation', () => {
|
|
85
|
+
afterEach(() => ConfigureRecordDataBroadcast([]));
|
|
86
|
+
|
|
87
|
+
it("honours a wildcard written with surrounding whitespace", () => {
|
|
88
|
+
ConfigureRecordDataBroadcast([' * ']);
|
|
89
|
+
expect(MayBroadcastRecordData('Users')).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ignores blank entries rather than letting one match something', () => {
|
|
93
|
+
ConfigureRecordDataBroadcast(['', ' ']);
|
|
94
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
95
|
+
// A blank entry must not become a wildcard by accident either.
|
|
96
|
+
expect(MayBroadcastRecordData('')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('still matches a normal name written with odd casing and padding', () => {
|
|
100
|
+
ConfigureRecordDataBroadcast([' AI MODELS ']);
|
|
101
|
+
expect(MayBroadcastRecordData('ai models')).toBe(true);
|
|
102
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Entity-level delivery filtering, raised in review.
|
|
108
|
+
*
|
|
109
|
+
* An event for an entity a session cannot read at all tells that session the record exists, its
|
|
110
|
+
* stable key and when it changed — and the session could never have read it, so withholding costs
|
|
111
|
+
* nothing. This does NOT close the row-level case: two sessions that both hold read permission on
|
|
112
|
+
* an entity still see each other's keys. That is tracked separately and deliberately not faked
|
|
113
|
+
* green here.
|
|
114
|
+
*/
|
|
115
|
+
describe('cacheInvalidationFilter', () => {
|
|
116
|
+
const event = (entityName: string) => ({
|
|
117
|
+
entityName,
|
|
118
|
+
primaryKeyValues: '[{"FieldName":"ID","Value":"r1"}]',
|
|
119
|
+
action: 'save',
|
|
120
|
+
sourceServerId: 's1',
|
|
121
|
+
timestamp: new Date(),
|
|
122
|
+
});
|
|
123
|
+
const session = (userRecord: unknown) => ({ userPayload: { userRecord } });
|
|
124
|
+
|
|
125
|
+
const withEntities = async (entities: Array<{ Name: string; CanRead: boolean }>) => {
|
|
126
|
+
vi.resetModules();
|
|
127
|
+
vi.doMock('@memberjunction/core', () => ({
|
|
128
|
+
Metadata: class {
|
|
129
|
+
get Entities() {
|
|
130
|
+
return entities.map((e) => ({
|
|
131
|
+
Name: e.Name,
|
|
132
|
+
GetUserPermisions: () => ({ CanRead: e.CanRead }),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
// The real one is case-insensitive and trims — the filter relies on that, so the mock does too.
|
|
136
|
+
EntityByName(name: string) {
|
|
137
|
+
const key = name.trim().toLowerCase();
|
|
138
|
+
return this.Entities.find((e) => e.Name.toLowerCase() === key);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}));
|
|
142
|
+
return (await import('../generic/CacheInvalidationResolver.js')).cacheInvalidationFilter;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
afterEach(() => vi.doUnmock('@memberjunction/core'));
|
|
146
|
+
|
|
147
|
+
it('delivers an event for an entity the session may read', async () => {
|
|
148
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
149
|
+
expect(filter({ payload: event('AI Models') as never, context: session({ ID: 'u1' }) })).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('withholds an event for an entity the session may NOT read', async () => {
|
|
153
|
+
const filter = await withEntities([{ Name: 'Users', CanRead: false }]);
|
|
154
|
+
expect(filter({ payload: event('Users') as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fails closed when the connection carries no user', async () => {
|
|
158
|
+
// onConnect rejects a socket whose token does not validate, so this should not arise — and it
|
|
159
|
+
// is the case least deserving of an unfiltered firehose if it does.
|
|
160
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
161
|
+
expect(filter({ payload: event('AI Models') as never, context: undefined })).toBe(false);
|
|
162
|
+
expect(filter({ payload: event('AI Models') as never, context: session(undefined) })).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('delivers when the entity is unknown to this server, rather than silently going stale', async () => {
|
|
166
|
+
// Deliberately the opposite choice: a name absent from metadata cannot be permission-checked,
|
|
167
|
+
// and withholding would stop invalidating a legitimately cacheable entity — a correctness bug
|
|
168
|
+
// in place of a disclosure one.
|
|
169
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
170
|
+
expect(filter({ payload: event('Some Entity This Server Never Heard Of') as never, context: session({ ID: 'u1' }) })).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('withholds an event with no entity name', async () => {
|
|
174
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
175
|
+
expect(filter({ payload: { ...event(''), entityName: '' } as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|