@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.
Files changed (108) hide show
  1. package/README.md +67 -0
  2. package/dist/apolloServer/index.d.ts +0 -8
  3. package/dist/apolloServer/index.d.ts.map +1 -1
  4. package/dist/apolloServer/index.js +61 -0
  5. package/dist/apolloServer/index.js.map +1 -1
  6. package/dist/config.d.ts +68 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +24 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +2 -21
  12. package/dist/context.js.map +1 -1
  13. package/dist/generated/generated.d.ts +75 -0
  14. package/dist/generated/generated.d.ts.map +1 -1
  15. package/dist/generated/generated.js +314 -1
  16. package/dist/generated/generated.js.map +1 -1
  17. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  18. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  19. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  20. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +120 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/logging/NoLog.d.ts +50 -0
  26. package/dist/logging/NoLog.d.ts.map +1 -0
  27. package/dist/logging/NoLog.js +80 -0
  28. package/dist/logging/NoLog.js.map +1 -0
  29. package/dist/logging/bootAudit.d.ts +43 -0
  30. package/dist/logging/bootAudit.d.ts.map +1 -0
  31. package/dist/logging/bootAudit.js +83 -0
  32. package/dist/logging/bootAudit.js.map +1 -0
  33. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  34. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  35. package/dist/logging/boundaryLogPayload.js +18 -0
  36. package/dist/logging/boundaryLogPayload.js.map +1 -0
  37. package/dist/logging/secretRedactor.d.ts +23 -0
  38. package/dist/logging/secretRedactor.d.ts.map +1 -0
  39. package/dist/logging/secretRedactor.js +53 -0
  40. package/dist/logging/secretRedactor.js.map +1 -0
  41. package/dist/logging/shortenForLog.d.ts +8 -0
  42. package/dist/logging/shortenForLog.d.ts.map +1 -0
  43. package/dist/logging/shortenForLog.js +21 -0
  44. package/dist/logging/shortenForLog.js.map +1 -0
  45. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  46. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  47. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  48. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  49. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  50. package/dist/resolvers/GetDataResolver.js +8 -4
  51. package/dist/resolvers/GetDataResolver.js.map +1 -1
  52. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  53. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  55. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  56. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  57. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  58. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  59. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  60. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  61. package/dist/resolvers/MCPResolver.js +19 -14
  62. package/dist/resolvers/MCPResolver.js.map +1 -1
  63. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  64. package/dist/resolvers/RunAIAgentResolver.js +26 -5
  65. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  66. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunTestResolver.js +12 -2
  68. package/dist/resolvers/RunTestResolver.js.map +1 -1
  69. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  70. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  71. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  72. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  73. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  74. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  75. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  76. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  77. package/dist/services/ScheduledJobsService.js +14 -2
  78. package/dist/services/ScheduledJobsService.js.map +1 -1
  79. package/package.json +75 -74
  80. package/src/__tests__/NoLog.test.ts +76 -0
  81. package/src/__tests__/bootAudit.test.ts +188 -0
  82. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  83. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  84. package/src/__tests__/secretRedactor.test.ts +163 -0
  85. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  86. package/src/apolloServer/index.ts +58 -0
  87. package/src/config.ts +27 -0
  88. package/src/context.ts +2 -19
  89. package/src/generated/generated.ts +239 -1
  90. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  91. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  92. package/src/index.ts +137 -2
  93. package/src/logging/NoLog.ts +88 -0
  94. package/src/logging/bootAudit.ts +117 -0
  95. package/src/logging/boundaryLogPayload.ts +19 -0
  96. package/src/logging/secretRedactor.ts +82 -0
  97. package/src/logging/shortenForLog.ts +17 -0
  98. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  99. package/src/resolvers/GetDataResolver.ts +9 -5
  100. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  101. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  102. package/src/resolvers/MCPResolver.ts +18 -14
  103. package/src/resolvers/RunAIAgentResolver.ts +28 -5
  104. package/src/resolvers/RunTestResolver.ts +14 -2
  105. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  106. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  107. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  108. 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
+ });