@memberjunction/server 5.37.0 → 5.39.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.
- package/README.md +79 -12
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +16 -12
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/apolloServer/index.d.ts +0 -8
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +61 -0
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -21
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +172 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +867 -14
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
- package/dist/generic/FireAndForgetHeartbeat.js +44 -0
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +177 -16
- package/dist/index.js.map +1 -1
- package/dist/logging/NoLog.d.ts +50 -0
- package/dist/logging/NoLog.d.ts.map +1 -0
- package/dist/logging/NoLog.js +80 -0
- package/dist/logging/NoLog.js.map +1 -0
- package/dist/logging/bootAudit.d.ts +43 -0
- package/dist/logging/bootAudit.d.ts.map +1 -0
- package/dist/logging/bootAudit.js +83 -0
- package/dist/logging/bootAudit.js.map +1 -0
- package/dist/logging/boundaryLogPayload.d.ts +18 -0
- package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
- package/dist/logging/boundaryLogPayload.js +18 -0
- package/dist/logging/boundaryLogPayload.js.map +1 -0
- package/dist/logging/secretRedactor.d.ts +23 -0
- package/dist/logging/secretRedactor.d.ts.map +1 -0
- package/dist/logging/secretRedactor.js +53 -0
- package/dist/logging/secretRedactor.js.map +1 -0
- package/dist/logging/shortenForLog.d.ts +8 -0
- package/dist/logging/shortenForLog.d.ts.map +1 -0
- package/dist/logging/shortenForLog.js +21 -0
- package/dist/logging/shortenForLog.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.js +127 -0
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +8 -4
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
- package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationProgressResolver.js +196 -0
- package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +19 -14
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +5 -4
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +6 -18
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +32 -64
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +12 -2
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchEntitiesResolver.js +216 -0
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -1
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +14 -2
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +75 -74
- package/src/__tests__/NoLog.test.ts +76 -0
- package/src/__tests__/bootAudit.test.ts +188 -0
- package/src/__tests__/boundaryLogPayload.test.ts +31 -0
- package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
- package/src/__tests__/resolverBase.rls.test.ts +224 -0
- package/src/__tests__/secretRedactor.test.ts +163 -0
- package/src/__tests__/subscriptionRedaction.test.ts +217 -0
- package/src/agents/skip-sdk.ts +16 -13
- package/src/apolloServer/index.ts +58 -0
- package/src/config.ts +27 -0
- package/src/context.ts +2 -19
- package/src/generated/generated.ts +627 -15
- package/src/generic/FireAndForgetHeartbeat.ts +85 -0
- package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
- package/src/index.ts +199 -19
- package/src/logging/NoLog.ts +88 -0
- package/src/logging/bootAudit.ts +117 -0
- package/src/logging/boundaryLogPayload.ts +19 -0
- package/src/logging/secretRedactor.ts +82 -0
- package/src/logging/shortenForLog.ts +17 -0
- package/src/logging/variablesLoggingMiddleware.ts +191 -0
- package/src/resolvers/GetDataResolver.ts +9 -5
- package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
- package/src/resolvers/IntegrationProgressResolver.ts +220 -0
- package/src/resolvers/MCPResolver.ts +18 -14
- package/src/resolvers/QueryResolver.ts +7 -6
- package/src/resolvers/QuerySystemUserResolver.ts +11 -25
- package/src/resolvers/RunAIAgentResolver.ts +34 -71
- package/src/resolvers/RunTestResolver.ts +14 -2
- package/src/resolvers/SearchEntitiesResolver.ts +173 -0
- package/src/resolvers/VectorizeEntityResolver.ts +14 -1
- package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
- package/src/services/ScheduledJobsService.ts +15 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import type { IMetadataProvider } from '@memberjunction/core';
|
|
4
|
+
import { redactArg } from '../logging/secretRedactor.js';
|
|
5
|
+
import { NoLog, getNoLogFields } from '../logging/NoLog.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test 4 (unit form): the `@NoLog`-decorated `Token` field on GetDataInputType is redacted
|
|
9
|
+
* from verbose logs, while the non-sensitive `Queries` field passes through.
|
|
10
|
+
*
|
|
11
|
+
* GetData is @RequireSystemUser (system-user-only), so this is verified at the redactor level
|
|
12
|
+
* rather than via a live request. It exercises the field-level @NoLog path: the middleware
|
|
13
|
+
* collects @NoLog field names via getNoLogFields() and passes them to redactArg as noLogFields.
|
|
14
|
+
*
|
|
15
|
+
* GetDataInputType is NOT a Create/Update/Delete*Input, so it fails the entity-binding regex and
|
|
16
|
+
* would otherwise pass through whole — the @NoLog field set is the ONLY thing protecting Token here.
|
|
17
|
+
* That's exactly the case @NoLog exists for: sensitive args metadata cannot identify.
|
|
18
|
+
*/
|
|
19
|
+
describe('Test 4: GetData Token redaction via field-level @NoLog', () => {
|
|
20
|
+
// Mirror the real GetDataInputType shape and apply @NoLog to Token at field level, as the source does.
|
|
21
|
+
class GetDataInputType {
|
|
22
|
+
Token = '';
|
|
23
|
+
Queries: string[] = [];
|
|
24
|
+
}
|
|
25
|
+
NoLog(GetDataInputType.prototype, 'Token');
|
|
26
|
+
|
|
27
|
+
const provider = { Entities: [] } as unknown as IMetadataProvider;
|
|
28
|
+
|
|
29
|
+
it('getNoLogFields picks up the Token field mark', () => {
|
|
30
|
+
const fields = getNoLogFields(GetDataInputType);
|
|
31
|
+
expect(fields.has('Token')).toBe(true);
|
|
32
|
+
expect(fields.has('Queries')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('masks field-level @NoLog Token on a NON-entity-bound input (the real GetData case)', () => {
|
|
36
|
+
// GetDataInput does NOT match Create/Update/Delete*Input. This is the exact case @NoLog exists
|
|
37
|
+
// for: a custom resolver input metadata cannot identify. The redactor must still honor the
|
|
38
|
+
// field-level @NoLog set and mask Token, while passing the non-sensitive Queries through.
|
|
39
|
+
const providerWithEntities = {
|
|
40
|
+
Entities: [{ ClassName: 'Something', EncryptedFields: [] }],
|
|
41
|
+
} as unknown as IMetadataProvider;
|
|
42
|
+
|
|
43
|
+
const result = redactArg({
|
|
44
|
+
inputTypeName: 'GetDataInput', // not entity-bound — regex won't match
|
|
45
|
+
rawValue: { Token: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE', Queries: ['SELECT 1', 'SELECT 2'] },
|
|
46
|
+
provider: providerWithEntities,
|
|
47
|
+
noLogParameter: false,
|
|
48
|
+
noLogFields: getNoLogFields(GetDataInputType),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual({ Token: '<redacted>', Queries: ['SELECT 1', 'SELECT 2'] });
|
|
52
|
+
expect(JSON.stringify(result)).not.toContain('FAKE_SYSTEM_TOKEN_DO_NOT_USE');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('redactArg masks Token field-by-field when input IS entity-bound (noLogFields wins over metadata)', () => {
|
|
56
|
+
// When the input type maps to an entity, redactArg walks top-level keys and applies noLogFields.
|
|
57
|
+
const providerWithEntity = {
|
|
58
|
+
Entities: [{ ClassName: 'GetDataThing', EncryptedFields: [] }],
|
|
59
|
+
} as unknown as IMetadataProvider;
|
|
60
|
+
|
|
61
|
+
const result = redactArg({
|
|
62
|
+
inputTypeName: 'CreateGetDataThingInput',
|
|
63
|
+
rawValue: { Token: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE', Queries: ['SELECT 1'] },
|
|
64
|
+
provider: providerWithEntity,
|
|
65
|
+
noLogParameter: false,
|
|
66
|
+
noLogFields: new Set(['Token']),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual({ Token: '<redacted>', Queries: ['SELECT 1'] });
|
|
70
|
+
expect(JSON.stringify(result)).not.toContain('FAKE_SYSTEM_TOKEN_DO_NOT_USE');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('parameter-level @NoLog masks the entire arg regardless of shape', () => {
|
|
74
|
+
// If Token were a top-level @Arg with @NoLog (parameter form), noLogParameter=true masks it whole.
|
|
75
|
+
const result = redactArg({
|
|
76
|
+
inputTypeName: 'String',
|
|
77
|
+
rawValue: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE',
|
|
78
|
+
provider,
|
|
79
|
+
noLogParameter: true,
|
|
80
|
+
noLogFields: new Set(),
|
|
81
|
+
});
|
|
82
|
+
expect(result).toBe('<redacted>');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ResolverBase.getRowLevelSecurityWhereClause.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the resolver's RLS path delegates correctly to the centralized
|
|
5
|
+
* exemption check in EntityInfo.GetUserRowLevelSecurityWhereClause. This was
|
|
6
|
+
* the code path that caused the bug: single-record GraphQL resolvers applied
|
|
7
|
+
* RLS filters even when the user held a role that exempted them.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// ─── Hoisted mocks ────────────────────────────────────────────────────────
|
|
12
|
+
const { mockUserCacheUsers } = vi.hoisted(() => ({
|
|
13
|
+
mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Stub external deps before imports
|
|
17
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
|
|
18
|
+
SQLServerDataProvider: class {},
|
|
19
|
+
UserCache: {
|
|
20
|
+
get Users() { return mockUserCacheUsers; },
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('cloudevents', () => ({
|
|
25
|
+
CloudEvent: class {},
|
|
26
|
+
httpTransport: () => () => undefined,
|
|
27
|
+
emitterFor: () => () => undefined,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('type-graphql', () => ({
|
|
31
|
+
Resolver: () => () => undefined,
|
|
32
|
+
Mutation: () => () => undefined,
|
|
33
|
+
Query: () => () => undefined,
|
|
34
|
+
Subscription: () => () => undefined,
|
|
35
|
+
Ctx: () => () => undefined,
|
|
36
|
+
Arg: () => () => undefined,
|
|
37
|
+
PubSub: () => () => undefined,
|
|
38
|
+
Root: () => () => undefined,
|
|
39
|
+
ObjectType: () => () => undefined,
|
|
40
|
+
InputType: () => () => undefined,
|
|
41
|
+
Field: () => () => undefined,
|
|
42
|
+
FieldResolver: () => () => undefined,
|
|
43
|
+
Int: () => undefined,
|
|
44
|
+
Float: () => undefined,
|
|
45
|
+
registerEnumType: () => undefined,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock('graphql', () => ({
|
|
49
|
+
GraphQLError: class extends Error {
|
|
50
|
+
constructor(msg: string) { super(msg); }
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('mssql', () => ({}));
|
|
55
|
+
|
|
56
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
57
|
+
GetAPIKeyEngine: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('@memberjunction/encryption', () => ({
|
|
61
|
+
EncryptionEngine: { Instance: {} },
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock('@memberjunction/graphql-dataprovider', () => ({
|
|
65
|
+
FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock('../generic/PubSubManager.js', () => ({
|
|
69
|
+
PubSubManager: class { static Instance = { publish: vi.fn() }; },
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock('../generic/PushStatusResolver.js', () => ({
|
|
73
|
+
PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
|
|
74
|
+
PushStatusNotification: class {},
|
|
75
|
+
PushStatusResolver: class {},
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock('../generic/CacheInvalidationResolver.js', () => ({
|
|
79
|
+
CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock('../generic/RunViewResolver.js', () => ({
|
|
83
|
+
RunViewByIDInput: class {},
|
|
84
|
+
RunViewByNameInput: class {},
|
|
85
|
+
RunDynamicViewInput: class {},
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
vi.mock('../generic/DeleteOptionsInput.js', () => ({
|
|
89
|
+
DeleteOptionsInput: class {},
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
vi.mock('../types.js', () => ({
|
|
93
|
+
RunViewGenericParams: class {},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
vi.mock('@memberjunction/core', async () => {
|
|
97
|
+
const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
|
|
98
|
+
return {
|
|
99
|
+
...actual,
|
|
100
|
+
LogError: vi.fn(),
|
|
101
|
+
LogStatus: vi.fn(),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
vi.mock('@memberjunction/core-entities', () => ({}));
|
|
106
|
+
|
|
107
|
+
// ─── Import after mocks ──────────────────────────────────────────────────
|
|
108
|
+
import { ResolverBase } from '../generic/ResolverBase';
|
|
109
|
+
import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction/core';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subclass that exposes the protected getRowLevelSecurityWhereClause for testing.
|
|
113
|
+
*/
|
|
114
|
+
class TestResolver extends ResolverBase {
|
|
115
|
+
public TestGetRLSWhereClause(
|
|
116
|
+
provider: DatabaseProviderBase,
|
|
117
|
+
entityName: string,
|
|
118
|
+
userPayload: { email: string },
|
|
119
|
+
type: string,
|
|
120
|
+
returnPrefix: string,
|
|
121
|
+
): string {
|
|
122
|
+
return (this as unknown as {
|
|
123
|
+
getRowLevelSecurityWhereClause: (
|
|
124
|
+
p: DatabaseProviderBase, e: string, u: { email: string }, t: string, r: string,
|
|
125
|
+
) => string;
|
|
126
|
+
}).getRowLevelSecurityWhereClause(provider, entityName, userPayload, type, returnPrefix);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function makeEntity(name: string, opts: {
|
|
133
|
+
exempt: boolean;
|
|
134
|
+
rlsClause: string;
|
|
135
|
+
}): EntityInfo {
|
|
136
|
+
return {
|
|
137
|
+
Name: name,
|
|
138
|
+
UserExemptFromRowLevelSecurity: () => opts.exempt,
|
|
139
|
+
GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
|
|
140
|
+
} as unknown as EntityInfo;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeProvider(entities: EntityInfo[]): DatabaseProviderBase {
|
|
144
|
+
return {
|
|
145
|
+
Entities: entities,
|
|
146
|
+
} as unknown as DatabaseProviderBase;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('ResolverBase.getRowLevelSecurityWhereClause', () => {
|
|
152
|
+
let resolver: TestResolver;
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
resolver = new TestResolver();
|
|
156
|
+
mockUserCacheUsers.length = 0;
|
|
157
|
+
mockUserCacheUsers.push({
|
|
158
|
+
Email: 'test@example.com',
|
|
159
|
+
ID: 'user-1',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns empty string when user is exempt from RLS', () => {
|
|
164
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: "OwnerID = 'user-1'" });
|
|
165
|
+
const provider = makeProvider([entity]);
|
|
166
|
+
|
|
167
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
168
|
+
provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(result).toBe('');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns RLS clause when user is NOT exempt', () => {
|
|
175
|
+
const entity = makeEntity('Test Entity', { exempt: false, rlsClause: "AND OwnerID = 'user-1'" });
|
|
176
|
+
const provider = makeProvider([entity]);
|
|
177
|
+
|
|
178
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
179
|
+
provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result).toContain('OwnerID');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('throws when entity not found', () => {
|
|
186
|
+
const provider = makeProvider([]);
|
|
187
|
+
|
|
188
|
+
expect(() => resolver.TestGetRLSWhereClause(
|
|
189
|
+
provider, 'Nonexistent Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
190
|
+
)).toThrow('Entity Nonexistent Entity not found');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('throws when user not found in UserCache', () => {
|
|
194
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
|
|
195
|
+
const provider = makeProvider([entity]);
|
|
196
|
+
mockUserCacheUsers.length = 0; // Clear cache
|
|
197
|
+
|
|
198
|
+
expect(() => resolver.TestGetRLSWhereClause(
|
|
199
|
+
provider, 'Test Entity', { email: 'unknown@example.com' }, 'Read', 'AND',
|
|
200
|
+
)).toThrow('User unknown@example.com not found');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('matches entity name case-insensitively', () => {
|
|
204
|
+
const entity = makeEntity('MJ: AI Prompt Runs', { exempt: true, rlsClause: '' });
|
|
205
|
+
const provider = makeProvider([entity]);
|
|
206
|
+
|
|
207
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
208
|
+
provider, 'mj: ai prompt runs', { email: 'test@example.com' }, 'Read', 'AND',
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(result).toBe('');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('matches user email case-insensitively', () => {
|
|
215
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
|
|
216
|
+
const provider = makeProvider([entity]);
|
|
217
|
+
|
|
218
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
219
|
+
provider, 'Test Entity', { email: 'TEST@EXAMPLE.COM' }, 'Read', 'AND',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(result).toBe('');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { IMetadataProvider } from '@memberjunction/core';
|
|
3
|
+
import { redactArg } from '../logging/secretRedactor.js';
|
|
4
|
+
|
|
5
|
+
type EncryptedField = { Name: string };
|
|
6
|
+
type FakeEntity = { ClassName: string; EncryptedFields: EncryptedField[] };
|
|
7
|
+
|
|
8
|
+
function makeProvider(entities: FakeEntity[]): IMetadataProvider {
|
|
9
|
+
return {
|
|
10
|
+
Entities: entities,
|
|
11
|
+
} as unknown as IMetadataProvider;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('redactArg', () => {
|
|
15
|
+
const credentialEntity: FakeEntity = {
|
|
16
|
+
ClassName: 'MJCredential',
|
|
17
|
+
EncryptedFields: [{ Name: 'Values' }],
|
|
18
|
+
};
|
|
19
|
+
const userEntity: FakeEntity = {
|
|
20
|
+
ClassName: 'User',
|
|
21
|
+
EncryptedFields: [],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it('returns "<redacted>" when noLogParameter is set, regardless of other inputs', () => {
|
|
25
|
+
const result = redactArg({
|
|
26
|
+
inputTypeName: 'CreateMJCredentialInput',
|
|
27
|
+
rawValue: { Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
|
|
28
|
+
provider: makeProvider([credentialEntity]),
|
|
29
|
+
noLogParameter: true,
|
|
30
|
+
noLogFields: new Set(),
|
|
31
|
+
});
|
|
32
|
+
expect(result).toBe('<redacted>');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns "<metadata-not-ready>" when the provider has no entities (bootstrap window)', () => {
|
|
36
|
+
const result = redactArg({
|
|
37
|
+
inputTypeName: 'CreateMJCredentialInput',
|
|
38
|
+
rawValue: { Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
|
|
39
|
+
provider: makeProvider([]),
|
|
40
|
+
noLogParameter: false,
|
|
41
|
+
noLogFields: new Set(),
|
|
42
|
+
});
|
|
43
|
+
expect(result).toBe('<metadata-not-ready>');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('fails open (passes through) when input type does not match Create/Update<X>Input regex', () => {
|
|
47
|
+
const result = redactArg({
|
|
48
|
+
inputTypeName: 'SomeCustomArgs',
|
|
49
|
+
rawValue: { accessToken: 'FAKE_TOKEN' },
|
|
50
|
+
provider: makeProvider([credentialEntity]),
|
|
51
|
+
noLogParameter: false,
|
|
52
|
+
noLogFields: new Set(),
|
|
53
|
+
});
|
|
54
|
+
expect(result).toEqual('{"accessToken":"FAKE_TOKEN"}');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('fails open when input type matches regex but no entity is found', () => {
|
|
58
|
+
const result = redactArg({
|
|
59
|
+
inputTypeName: 'CreateUnknownEntityInput',
|
|
60
|
+
rawValue: { Foo: 'bar' },
|
|
61
|
+
provider: makeProvider([credentialEntity]),
|
|
62
|
+
noLogParameter: false,
|
|
63
|
+
noLogFields: new Set(),
|
|
64
|
+
});
|
|
65
|
+
expect(result).toEqual('{"Foo":"bar"}');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('redacts top-level keys matching EntityFieldInfo.Encrypt=true on entity-bound CRUD inputs', () => {
|
|
69
|
+
const result = redactArg({
|
|
70
|
+
inputTypeName: 'CreateMJCredentialInput',
|
|
71
|
+
rawValue: { ID: 'abc-123', Name: 'HubSpot', Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
|
|
72
|
+
provider: makeProvider([credentialEntity]),
|
|
73
|
+
noLogParameter: false,
|
|
74
|
+
noLogFields: new Set(),
|
|
75
|
+
});
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
ID: 'abc-123',
|
|
78
|
+
Name: 'HubSpot',
|
|
79
|
+
Values: '<redacted>',
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('redacts top-level keys in noLogFields even when EncryptedFields does not include them', () => {
|
|
84
|
+
const result = redactArg({
|
|
85
|
+
inputTypeName: 'CreateUserInput',
|
|
86
|
+
rawValue: { Name: 'Alice', SecretToken: 'oops' },
|
|
87
|
+
provider: makeProvider([userEntity]),
|
|
88
|
+
noLogParameter: false,
|
|
89
|
+
noLogFields: new Set(['SecretToken']),
|
|
90
|
+
});
|
|
91
|
+
expect(result).toEqual({
|
|
92
|
+
Name: 'Alice',
|
|
93
|
+
SecretToken: '<redacted>',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('Update<X>Input is also handled (not just Create<X>Input)', () => {
|
|
98
|
+
const result = redactArg({
|
|
99
|
+
inputTypeName: 'UpdateMJCredentialInput',
|
|
100
|
+
rawValue: { ID: 'abc-123', Values: 'NEW_FAKE_SECRET' },
|
|
101
|
+
provider: makeProvider([credentialEntity]),
|
|
102
|
+
noLogParameter: false,
|
|
103
|
+
noLogFields: new Set(),
|
|
104
|
+
});
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
ID: 'abc-123',
|
|
107
|
+
Values: '<redacted>',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('Delete<X>Input resolves to its entity and walks keys (PK only — no encrypted values to redact)', () => {
|
|
112
|
+
// Delete is matched by the regex (alongside Create/Update) so the boot audit treats DeleteMJ*
|
|
113
|
+
// resolvers as metadata-bound instead of flooding with false positives. Security is identical:
|
|
114
|
+
// delete inputs carry PK + Options only — no encrypted field, so nothing is redacted, the keys
|
|
115
|
+
// just pass through. See docs/adr/0001-graphql-variables-logging-tiered-by-verbose.md.
|
|
116
|
+
const result = redactArg({
|
|
117
|
+
inputTypeName: 'DeleteMJCredentialInput',
|
|
118
|
+
rawValue: { ID: 'abc-123' },
|
|
119
|
+
provider: makeProvider([credentialEntity]),
|
|
120
|
+
noLogParameter: false,
|
|
121
|
+
noLogFields: new Set(),
|
|
122
|
+
});
|
|
123
|
+
expect(result).toEqual({ ID: 'abc-123' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('Delete<X>Input never exposes an encrypted value even if one were somehow present', () => {
|
|
127
|
+
// Defense-in-depth: even if a delete input carried an encrypted field name, it must still mask.
|
|
128
|
+
const result = redactArg({
|
|
129
|
+
inputTypeName: 'DeleteMJCredentialInput',
|
|
130
|
+
rawValue: { ID: 'abc-123', Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
|
|
131
|
+
provider: makeProvider([credentialEntity]),
|
|
132
|
+
noLogParameter: false,
|
|
133
|
+
noLogFields: new Set(),
|
|
134
|
+
});
|
|
135
|
+
expect(result).toEqual({ ID: 'abc-123', Values: '<redacted>' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('only walks TOP-level keys — nested objects with encrypted-field names are not recursed', () => {
|
|
139
|
+
const result = redactArg({
|
|
140
|
+
inputTypeName: 'CreateMJCredentialInput',
|
|
141
|
+
rawValue: {
|
|
142
|
+
Name: 'HubSpot',
|
|
143
|
+
Nested: { Values: 'NESTED_FAKE_SECRET' },
|
|
144
|
+
},
|
|
145
|
+
provider: makeProvider([credentialEntity]),
|
|
146
|
+
noLogParameter: false,
|
|
147
|
+
noLogFields: new Set(),
|
|
148
|
+
});
|
|
149
|
+
// Top-level `Values` would be redacted; nested `Values` is not.
|
|
150
|
+
expect((result as Record<string, unknown>).Nested).toEqual('{"Values":"NESTED_FAKE_SECRET"}');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('passes non-object raw values through to shortenForLog', () => {
|
|
154
|
+
const result = redactArg({
|
|
155
|
+
inputTypeName: 'CreateMJCredentialInput',
|
|
156
|
+
rawValue: 'plain-string',
|
|
157
|
+
provider: makeProvider([credentialEntity]),
|
|
158
|
+
noLogParameter: false,
|
|
159
|
+
noLogFields: new Set(),
|
|
160
|
+
});
|
|
161
|
+
expect(result).toBe('plain-string');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware wiring test.
|
|
3
|
+
*
|
|
4
|
+
* Locks the assumption that `variablesLoggingMiddleware` correctly:
|
|
5
|
+
* - Skips field resolvers (info.path.prev !== undefined)
|
|
6
|
+
* - Short-circuits when logVariables=false
|
|
7
|
+
* - Emits a redacted variables block when logVariables=true
|
|
8
|
+
* - Respects @NoLog field-level marks via the input class lookup
|
|
9
|
+
*
|
|
10
|
+
* We invoke the middleware directly with a synthetic ResolverData rather than booting a real
|
|
11
|
+
* GraphQL execution — that would force two copies of `graphql` (CJS via type-graphql + ESM via
|
|
12
|
+
* the test runner) into the same realm and fail with "Cannot use GraphQLSchema from another
|
|
13
|
+
* module or realm." The wiring at schema-build time (which IS what we want to lock for future
|
|
14
|
+
* type-graphql upgrades) is exercised in the end-to-end verification described in the PRD;
|
|
15
|
+
* here we just lock the per-call redaction logic.
|
|
16
|
+
*
|
|
17
|
+
* `globalMiddlewares` covers `@Query`, `@Mutation`, and `@Subscription` resolvers identically
|
|
18
|
+
* — they all go through the same `applyMiddlewares` chain inside type-graphql. Asserting the
|
|
19
|
+
* middleware fires once for a representative root resolver covers all three.
|
|
20
|
+
*/
|
|
21
|
+
import 'reflect-metadata';
|
|
22
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
23
|
+
|
|
24
|
+
vi.mock('../config', () => ({
|
|
25
|
+
configInfo: {
|
|
26
|
+
loggingSettings: {
|
|
27
|
+
graphql: {
|
|
28
|
+
logVariables: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('../config.js', () => ({
|
|
34
|
+
configInfo: {
|
|
35
|
+
loggingSettings: {
|
|
36
|
+
graphql: {
|
|
37
|
+
logVariables: true,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { variablesLoggingMiddleware } from '../logging/variablesLoggingMiddleware.js';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a synthetic GraphQLResolveInfo-shaped object that matches what type-graphql passes
|
|
47
|
+
* to a middleware at runtime. Only the subset we read is populated.
|
|
48
|
+
*/
|
|
49
|
+
function makeInfo(opts: {
|
|
50
|
+
fieldName: string;
|
|
51
|
+
argTypeName: string;
|
|
52
|
+
argName: string;
|
|
53
|
+
isFieldResolver?: boolean;
|
|
54
|
+
}): unknown {
|
|
55
|
+
const fakeArg = {
|
|
56
|
+
name: opts.argName,
|
|
57
|
+
type: { name: opts.argTypeName },
|
|
58
|
+
};
|
|
59
|
+
const fakeField = {
|
|
60
|
+
args: [fakeArg],
|
|
61
|
+
};
|
|
62
|
+
const parentType = {
|
|
63
|
+
getFields: () => ({ [opts.fieldName]: fakeField }),
|
|
64
|
+
};
|
|
65
|
+
const path = opts.isFieldResolver
|
|
66
|
+
? { prev: { prev: undefined, key: 'parent' }, key: opts.fieldName }
|
|
67
|
+
: { prev: undefined, key: opts.fieldName };
|
|
68
|
+
return {
|
|
69
|
+
fieldName: opts.fieldName,
|
|
70
|
+
parentType,
|
|
71
|
+
path,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Stub `getNamedType` is unnecessary — middleware uses graphql's `getNamedType` against the
|
|
76
|
+
// type we pass. Our `argTypeName` value is wrapped in a plain `{ name }` object below; we need
|
|
77
|
+
// the middleware's `getNamedType` to read `.name` directly. The `graphql` library's
|
|
78
|
+
// `getNamedType` unwraps NonNull/List wrappers; a plain `{ name }` object survives unwrapping
|
|
79
|
+
// because none of those wrapper shapes are present.
|
|
80
|
+
|
|
81
|
+
describe('variablesLoggingMiddleware', () => {
|
|
82
|
+
let dirSpy: ReturnType<typeof vi.spyOn>;
|
|
83
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
dirSpy = vi.spyOn(console, 'dir').mockImplementation(() => {});
|
|
87
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
dirSpy.mockRestore();
|
|
92
|
+
warnSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('skips field resolvers (info.path.prev !== undefined) without logging', async () => {
|
|
96
|
+
const next = vi.fn(async () => 'next-result');
|
|
97
|
+
const result = await variablesLoggingMiddleware(
|
|
98
|
+
{
|
|
99
|
+
root: undefined,
|
|
100
|
+
args: { x: 1 },
|
|
101
|
+
context: {} as never,
|
|
102
|
+
info: makeInfo({
|
|
103
|
+
fieldName: 'nestedThing',
|
|
104
|
+
argTypeName: 'CreateMJCredentialInput',
|
|
105
|
+
argName: 'input',
|
|
106
|
+
isFieldResolver: true,
|
|
107
|
+
}) as never,
|
|
108
|
+
},
|
|
109
|
+
next,
|
|
110
|
+
);
|
|
111
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(result).toBe('next-result');
|
|
113
|
+
expect(dirSpy).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('emits a redacted variables block for a root resolver with an entity-bound CRUD input', async () => {
|
|
117
|
+
const next = vi.fn(async () => 'next-result');
|
|
118
|
+
const fakeProvider = {
|
|
119
|
+
Entities: [
|
|
120
|
+
{ ClassName: 'MJCredential', EncryptedFields: [{ Name: 'Values' }] },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
await variablesLoggingMiddleware(
|
|
125
|
+
{
|
|
126
|
+
root: undefined,
|
|
127
|
+
args: { input: { ID: 'abc', Name: 'HubSpot', Values: 'FAKE_SECRET' } },
|
|
128
|
+
context: {
|
|
129
|
+
providers: [{ provider: fakeProvider, type: 'Read-Write' }],
|
|
130
|
+
} as never,
|
|
131
|
+
info: makeInfo({
|
|
132
|
+
fieldName: 'CreateMJCredential',
|
|
133
|
+
argTypeName: 'CreateMJCredentialInput',
|
|
134
|
+
argName: 'input',
|
|
135
|
+
}) as never,
|
|
136
|
+
},
|
|
137
|
+
next,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(dirSpy).toHaveBeenCalledTimes(1);
|
|
142
|
+
const [logged] = dirSpy.mock.calls[0];
|
|
143
|
+
expect(logged).toEqual({
|
|
144
|
+
operation: 'CreateMJCredential',
|
|
145
|
+
args: {
|
|
146
|
+
input: {
|
|
147
|
+
ID: 'abc',
|
|
148
|
+
Name: 'HubSpot',
|
|
149
|
+
Values: '<redacted>',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('logs a custom-resolver plaintext arg (no metadata redaction available for it)', async () => {
|
|
156
|
+
const next = vi.fn(async () => undefined);
|
|
157
|
+
// Populate Entities (non-empty) so the redactor does not fall into the bootstrap fail-closed branch.
|
|
158
|
+
// The String-typed arg doesn't match Create/Update<X>Input, so it falls through to shortenForLog.
|
|
159
|
+
const fakeProvider = {
|
|
160
|
+
Entities: [{ ClassName: 'SomeUnrelatedEntity', EncryptedFields: [] }],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await variablesLoggingMiddleware(
|
|
164
|
+
{
|
|
165
|
+
root: undefined,
|
|
166
|
+
args: { token: 'plaintext-leak' },
|
|
167
|
+
context: {
|
|
168
|
+
providers: [{ provider: fakeProvider, type: 'Read-Write' }],
|
|
169
|
+
} as never,
|
|
170
|
+
info: makeInfo({
|
|
171
|
+
fieldName: 'VoiceTestHubSpotCredential',
|
|
172
|
+
argTypeName: 'String',
|
|
173
|
+
argName: 'token',
|
|
174
|
+
}) as never,
|
|
175
|
+
},
|
|
176
|
+
next,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(dirSpy).toHaveBeenCalledTimes(1);
|
|
180
|
+
// Arg is logged as-is because String type doesn't match Create/Update<X>Input, so the
|
|
181
|
+
// redactor's fail-open branch passes the value through shortenForLog (plain string passthrough).
|
|
182
|
+
// This is the gap that @NoLog discipline plus the boot audit are designed to surface.
|
|
183
|
+
const [logged] = dirSpy.mock.calls[0];
|
|
184
|
+
expect(logged).toMatchObject({
|
|
185
|
+
operation: 'VoiceTestHubSpotCredential',
|
|
186
|
+
args: { token: 'plaintext-leak' },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('fails closed (<metadata-not-ready>) when provider.Entities is empty', async () => {
|
|
191
|
+
const next = vi.fn(async () => undefined);
|
|
192
|
+
const fakeProvider = { Entities: [] };
|
|
193
|
+
|
|
194
|
+
await variablesLoggingMiddleware(
|
|
195
|
+
{
|
|
196
|
+
root: undefined,
|
|
197
|
+
args: { input: { Values: 'FAKE_SECRET' } },
|
|
198
|
+
context: {
|
|
199
|
+
providers: [{ provider: fakeProvider, type: 'Read-Write' }],
|
|
200
|
+
} as never,
|
|
201
|
+
info: makeInfo({
|
|
202
|
+
fieldName: 'CreateMJCredential',
|
|
203
|
+
argTypeName: 'CreateMJCredentialInput',
|
|
204
|
+
argName: 'input',
|
|
205
|
+
}) as never,
|
|
206
|
+
},
|
|
207
|
+
next,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(dirSpy).toHaveBeenCalledTimes(1);
|
|
211
|
+
const [logged] = dirSpy.mock.calls[0];
|
|
212
|
+
expect(logged).toEqual({
|
|
213
|
+
operation: 'CreateMJCredential',
|
|
214
|
+
args: { input: '<metadata-not-ready>' },
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|