@memberjunction/server 5.38.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 +67 -0
- 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 +75 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +314 -1
- 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 +120 -2
- 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/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +26 -5
- 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__/secretRedactor.test.ts +163 -0
- package/src/__tests__/subscriptionRedaction.test.ts +217 -0
- package/src/apolloServer/index.ts +58 -0
- package/src/config.ts +27 -0
- package/src/context.ts +2 -19
- package/src/generated/generated.ts +239 -1
- package/src/generic/FireAndForgetHeartbeat.ts +85 -0
- package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
- package/src/index.ts +137 -2
- 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/RunAIAgentResolver.ts +28 -5
- 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,188 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Short-circuits config.ts's loadConfig() so this file loads in any environment, not just one
|
|
5
|
+
// with DB env vars set. bootAudit.ts imports `configInfo` at module-init; without this mock,
|
|
6
|
+
// `loadConfig()` runs and throws "Configuration validation failed" on a clean machine or in
|
|
7
|
+
// sandboxed CI. The audit tests below call `auditResolverList` directly and don't read
|
|
8
|
+
// `logVariables`, but the import chain still pulls in `configInfo`. Mirrors the same shim in
|
|
9
|
+
// subscriptionRedaction.test.ts.
|
|
10
|
+
vi.mock('../config', () => ({
|
|
11
|
+
configInfo: {
|
|
12
|
+
loggingSettings: { graphql: { logVariables: true } },
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../config.js', () => ({
|
|
16
|
+
configInfo: {
|
|
17
|
+
loggingSettings: { graphql: { logVariables: true } },
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { auditResolverList } from '../logging/bootAudit.js';
|
|
22
|
+
import type { AuditArgParam, AuditResolver } from '../logging/bootAudit.js';
|
|
23
|
+
import { NoLog } from '../logging/NoLog.js';
|
|
24
|
+
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
// Synthetic fixture types & helpers
|
|
27
|
+
// --------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
class CreateMJCredentialInput {}
|
|
30
|
+
class CustomSecretInput {}
|
|
31
|
+
|
|
32
|
+
function makeArgParam(opts: {
|
|
33
|
+
target: Function;
|
|
34
|
+
methodName: string;
|
|
35
|
+
index: number;
|
|
36
|
+
name: string;
|
|
37
|
+
type: Function;
|
|
38
|
+
}): AuditArgParam {
|
|
39
|
+
return {
|
|
40
|
+
target: opts.target,
|
|
41
|
+
methodName: opts.methodName,
|
|
42
|
+
index: opts.index,
|
|
43
|
+
kind: 'arg',
|
|
44
|
+
name: opts.name,
|
|
45
|
+
getType: () => opts.type,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeResolver(opts: {
|
|
50
|
+
target: Function;
|
|
51
|
+
methodName: string;
|
|
52
|
+
params: AuditArgParam[];
|
|
53
|
+
}): AuditResolver {
|
|
54
|
+
return {
|
|
55
|
+
target: opts.target,
|
|
56
|
+
methodName: opts.methodName,
|
|
57
|
+
params: opts.params,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --------------------------------------------------------------------------
|
|
62
|
+
// Tests
|
|
63
|
+
// --------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('auditResolverList', () => {
|
|
66
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
warnSpy.mockRestore();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does NOT warn for an @Arg whose type matches Create<X>Input', () => {
|
|
77
|
+
class SafeCrudResolver {
|
|
78
|
+
doCreate(_input: CreateMJCredentialInput): void {
|
|
79
|
+
// body
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const resolver = makeResolver({
|
|
83
|
+
target: SafeCrudResolver,
|
|
84
|
+
methodName: 'doCreate',
|
|
85
|
+
params: [
|
|
86
|
+
makeArgParam({
|
|
87
|
+
target: SafeCrudResolver,
|
|
88
|
+
methodName: 'doCreate',
|
|
89
|
+
index: 0,
|
|
90
|
+
name: 'input',
|
|
91
|
+
type: CreateMJCredentialInput,
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
auditResolverList([resolver]);
|
|
97
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('WARNS for an @Arg of a custom type without @NoLog', () => {
|
|
101
|
+
class CustomNoMarkResolver {
|
|
102
|
+
doCustom(_input: CustomSecretInput): void {
|
|
103
|
+
// body
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const resolver = makeResolver({
|
|
107
|
+
target: CustomNoMarkResolver,
|
|
108
|
+
methodName: 'doCustom',
|
|
109
|
+
params: [
|
|
110
|
+
makeArgParam({
|
|
111
|
+
target: CustomNoMarkResolver,
|
|
112
|
+
methodName: 'doCustom',
|
|
113
|
+
index: 0,
|
|
114
|
+
name: 'input',
|
|
115
|
+
type: CustomSecretInput,
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
auditResolverList([resolver]);
|
|
121
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
122
|
+
const message = warnSpy.mock.calls[0][0];
|
|
123
|
+
expect(typeof message).toBe('string');
|
|
124
|
+
expect(message).toContain('CustomNoMarkResolver.doCustom');
|
|
125
|
+
expect(message).toContain("'input'");
|
|
126
|
+
expect(message).toContain('CustomSecretInput');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does NOT warn for an @Arg of a custom type marked @NoLog at parameter level', () => {
|
|
130
|
+
class CustomMarkedResolver {
|
|
131
|
+
doCustom(_input: CustomSecretInput): void {
|
|
132
|
+
// body
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
NoLog(CustomMarkedResolver.prototype, 'doCustom', 0);
|
|
136
|
+
|
|
137
|
+
const resolver = makeResolver({
|
|
138
|
+
target: CustomMarkedResolver,
|
|
139
|
+
methodName: 'doCustom',
|
|
140
|
+
params: [
|
|
141
|
+
makeArgParam({
|
|
142
|
+
target: CustomMarkedResolver,
|
|
143
|
+
methodName: 'doCustom',
|
|
144
|
+
index: 0,
|
|
145
|
+
name: 'input',
|
|
146
|
+
type: CustomSecretInput,
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
auditResolverList([resolver]);
|
|
152
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('mixed resolver: warns for the un-decorated custom arg but not the metadata-bound one', () => {
|
|
156
|
+
class MixedResolver {
|
|
157
|
+
doMixed(_safe: CreateMJCredentialInput, _custom: CustomSecretInput): void {
|
|
158
|
+
// body
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const resolver = makeResolver({
|
|
162
|
+
target: MixedResolver,
|
|
163
|
+
methodName: 'doMixed',
|
|
164
|
+
params: [
|
|
165
|
+
makeArgParam({
|
|
166
|
+
target: MixedResolver,
|
|
167
|
+
methodName: 'doMixed',
|
|
168
|
+
index: 0,
|
|
169
|
+
name: 'safe',
|
|
170
|
+
type: CreateMJCredentialInput,
|
|
171
|
+
}),
|
|
172
|
+
makeArgParam({
|
|
173
|
+
target: MixedResolver,
|
|
174
|
+
methodName: 'doMixed',
|
|
175
|
+
index: 1,
|
|
176
|
+
name: 'custom',
|
|
177
|
+
type: CustomSecretInput,
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
auditResolverList([resolver]);
|
|
183
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
184
|
+
const message = warnSpy.mock.calls[0][0];
|
|
185
|
+
expect(message).toContain("'custom'");
|
|
186
|
+
expect(message).not.toContain("'safe'");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildBoundaryLogPayload } from '../logging/boundaryLogPayload.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guards the load-bearing #2638 fix: the always-on GraphQL boundary log line emits the
|
|
6
|
+
* operation name ONLY and never a `variables` key, in any configuration. A future refactor
|
|
7
|
+
* that re-adds variables to this line (the original leak) must fail here.
|
|
8
|
+
*
|
|
9
|
+
* See docs/adr/0001-graphql-variables-logging-tiered-by-verbose.md.
|
|
10
|
+
*/
|
|
11
|
+
describe('buildBoundaryLogPayload', () => {
|
|
12
|
+
it('returns operation name only', () => {
|
|
13
|
+
expect(buildBoundaryLogPayload('CreateMJCredential')).toEqual({ operationName: 'CreateMJCredential' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('never includes a `variables` key', () => {
|
|
17
|
+
const payload = buildBoundaryLogPayload('RunViewsWithCacheCheck');
|
|
18
|
+
expect('variables' in payload).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('serialized form contains no value content — only the operation name', () => {
|
|
22
|
+
// Even if someone passes a secret-shaped operation name, nothing but the name is emitted.
|
|
23
|
+
const serialized = JSON.stringify(buildBoundaryLogPayload('SomeOp'));
|
|
24
|
+
expect(serialized).toBe('{"operationName":"SomeOp"}');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles undefined operation name (non-named operations)', () => {
|
|
28
|
+
expect(buildBoundaryLogPayload(undefined)).toEqual({ operationName: undefined });
|
|
29
|
+
expect('variables' in buildBoundaryLogPayload(undefined)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -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,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
|
+
});
|