Skip to content

Commit 246f330

Browse files
test(core): Expand secret field coverage from review
- Administrator secret custom field preserved via updateActiveAdministrator (the alias-input corruption regression). - Config arg strategy input carries code and derived entityType/field. - Strengthen the owning-entity assertion to rule out the customFields wrapper. - Transformer to()/unconfigured/undecryptable-value paths; GCM tamper and malformed ciphertext. Relates to #2648
1 parent 6ce4cb9 commit 246f330

4 files changed

Lines changed: 139 additions & 7 deletions

File tree

packages/core/e2e/config-arg-secret.e2e-spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
mergeConfig,
99
PaymentMethod,
1010
PaymentMethodHandler,
11+
RequestContext,
12+
SecretAccessInput,
13+
SecretAccessStrategy,
1114
TransactionalConnection,
1215
} from '@vendure/core';
1316
import { createTestEnvironment } from '@vendure/testing';
@@ -58,6 +61,16 @@ const secretCollectionFilter = new CollectionFilter({
5861
const PLAINTEXT_KEY = 'sk_live_supersecret';
5962
const FILTER_PLAINTEXT_KEY = 'ck_live_collectionsecret';
6063

64+
// Captures the input passed to the strategy so a test can assert the derived owner (entityType/field)
65+
// and code for a config arg, while preserving the default permission-based reveal decision.
66+
let capturedSecretAccessInput: SecretAccessInput | undefined;
67+
class CapturingSecretAccessStrategy implements SecretAccessStrategy {
68+
canAccessSecret(ctx: RequestContext, input: SecretAccessInput): boolean {
69+
capturedSecretAccessInput = input;
70+
return ctx.userHasPermissions([Permission.ReadSecret]);
71+
}
72+
}
73+
6174
// #2648 — `secret` config arg values must be encrypted at rest and never returned in plaintext
6275
// to a caller without the ReadSecret permission.
6376
describe('secret config args', () => {
@@ -71,6 +84,7 @@ describe('secret config args', () => {
7184
},
7285
systemOptions: {
7386
encryptionStrategy: new DefaultEncryptionStrategy({ secret: 'test-encryption-key' }),
87+
secretAccessStrategy: new CapturingSecretAccessStrategy(),
7488
},
7589
}),
7690
);
@@ -141,6 +155,20 @@ describe('secret config args', () => {
141155
expect(apiKey?.value).toBe(PLAINTEXT_KEY);
142156
});
143157

158+
it('provides the code and derived owner (entityType/field) to the strategy for a config arg', async () => {
159+
capturedSecretAccessInput = undefined;
160+
await adminClient.asSuperAdmin();
161+
await adminClient.query(getPaymentMethodDocument, { id: paymentMethodId });
162+
const captured = capturedSecretAccessInput as SecretAccessInput | undefined;
163+
expect(captured?.kind).toBe('configArg');
164+
if (captured?.kind === 'configArg') {
165+
expect(captured.code).toBe(secretPaymentHandler.code);
166+
expect(captured.argName).toBe('apiKey');
167+
expect(captured.entityType).toBe('PaymentMethod');
168+
expect(captured.field).toBe('handler');
169+
}
170+
});
171+
144172
it('stores the secret arg encrypted at rest', async () => {
145173
const connection = server.app.get(TransactionalConnection);
146174
const stored = await connection.rawConnection

packages/core/e2e/custom-field-secret.e2e-spec.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe('secret custom fields', () => {
7373
{ name: 'secretKey', type: 'string', secret: true },
7474
{ name: 'note', type: 'string' },
7575
],
76+
Administrator: [{ name: 'apiToken', type: 'string', secret: true }],
7677
},
7778
systemOptions: {
7879
encryptionStrategy: new DefaultEncryptionStrategy({ secret: 'test-encryption-key' }),
@@ -82,6 +83,7 @@ describe('secret custom fields', () => {
8283
);
8384

8485
const manager = { emailAddress: 'cf-secret-manager@test.com', password: 'test-password' };
86+
let managerAdminId: string;
8587

8688
beforeAll(async () => {
8789
await server.init({
@@ -99,7 +101,7 @@ describe('secret custom fields', () => {
99101
channelIds: ['T_1'],
100102
},
101103
});
102-
await adminClient.query(createAdministratorDocument, {
104+
const { createAdministrator } = await adminClient.query(createAdministratorDocument, {
103105
input: {
104106
emailAddress: manager.emailAddress,
105107
firstName: 'CF',
@@ -108,6 +110,7 @@ describe('secret custom fields', () => {
108110
roleIds: [createRole.id],
109111
},
110112
});
113+
managerAdminId = createAdministrator.id;
111114
}, TEST_SETUP_TIMEOUT_MS);
112115

113116
afterAll(async () => {
@@ -170,9 +173,69 @@ describe('secret custom fields', () => {
170173
const captured = capturedSecretAccessInput as SecretAccessInput | undefined;
171174
expect(captured?.kind).toBe('customField');
172175
const entity = captured?.kind === 'customField' ? captured.entity : undefined;
176+
// Must be the owning Product entity, not the customFields wrapper. The entity holds its custom
177+
// fields under a nested `customFields` object; the wrapper instead spreads them at the top
178+
// level (so it would have `secretKey` directly and no nested `customFields`).
179+
expect((entity as any)?.customFields?.secretKey).toBe('sk_entity_check');
180+
expect((entity as any)?.secretKey).toBeUndefined();
173181
expect(entity?.id).toBeTruthy();
174-
// The owning entity carries a nested `customFields` object; the wrapper does not.
175-
expect((entity as any)?.customFields).toBeDefined();
182+
});
183+
184+
// Gabriel review — secret custom fields on an entity edited via an alias input type (here
185+
// `updateActiveAdministrator`, an admin saving their own profile) must be preserved on a
186+
// placeholder resubmit, not corrupted. This is the common case that the Product-only suites missed.
187+
it('preserves a secret custom field edited via updateActiveAdministrator (alias input)', async () => {
188+
const SET_ADMIN_TOKEN = gql`
189+
mutation SetAdminToken($input: UpdateAdministratorInput!) {
190+
updateAdministrator(input: $input) {
191+
id
192+
}
193+
}
194+
`;
195+
const GET_ADMIN_TOKEN = gql`
196+
query GetAdminToken($id: ID!) {
197+
administrator(id: $id) {
198+
id
199+
customFields {
200+
apiToken
201+
}
202+
}
203+
}
204+
`;
205+
const UPDATE_ACTIVE_ADMIN = gql`
206+
mutation UpdateActiveAdmin($input: UpdateActiveAdministratorInput!) {
207+
updateActiveAdministrator(input: $input) {
208+
id
209+
}
210+
}
211+
`;
212+
// As SuperAdmin, set the manager's secret token.
213+
await adminClient.asSuperAdmin();
214+
await adminClient.query(SET_ADMIN_TOKEN, {
215+
input: { id: managerAdminId, customFields: { apiToken: 'admin_secret_token' } },
216+
});
217+
218+
// The manager (no ReadSecret) sees the placeholder and saves their own profile back with it.
219+
await adminClient.asUserWithCredentials(manager.emailAddress, manager.password);
220+
const { activeAdministrator } = await adminClient.query(gql`
221+
query {
222+
activeAdministrator {
223+
id
224+
customFields {
225+
apiToken
226+
}
227+
}
228+
}
229+
`);
230+
expect(activeAdministrator.customFields.apiToken).toBe(REDACTED_SECRET_PLACEHOLDER);
231+
await adminClient.query(UPDATE_ACTIVE_ADMIN, {
232+
input: { firstName: 'Renamed', customFields: { apiToken: REDACTED_SECRET_PLACEHOLDER } },
233+
});
234+
235+
// The stored secret must be preserved, not overwritten with the encrypted placeholder.
236+
await adminClient.asSuperAdmin();
237+
const { administrator } = await adminClient.query(GET_ADMIN_TOKEN, { id: managerAdminId });
238+
expect(administrator.customFields.apiToken).toBe('admin_secret_token');
176239
});
177240

178241
it(
@@ -192,6 +255,6 @@ describe('secret custom fields', () => {
192255
customFields: { secretKey: REDACTED_SECRET_PLACEHOLDER },
193256
},
194257
});
195-
}, 'A value must be provided for the secret argument "secretKey"'),
258+
}, 'A value must be provided for the secret field "secretKey"'),
196259
);
197260
});

packages/core/src/config/system/default-encryption-strategy.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,20 @@ describe('DefaultEncryptionStrategy', () => {
5858
const encrypted = a.encrypt('secret-value');
5959
expect(() => b.decrypt(encrypted)).toThrow();
6060
});
61+
62+
it('rejects tampered ciphertext (GCM integrity)', () => {
63+
const strategy = configured();
64+
const encrypted = strategy.encrypt('secret-value');
65+
// Flip the last character of the ciphertext's data segment.
66+
const parts = encrypted.split(':');
67+
const data = parts[parts.length - 1];
68+
const flipped = (data.slice(0, -1) + (data.endsWith('A') ? 'B' : 'A')) as string;
69+
parts[parts.length - 1] = flipped;
70+
expect(() => strategy.decrypt(parts.join(':'))).toThrow();
71+
});
72+
73+
it('throws a clear error on a malformed ciphertext', () => {
74+
const strategy = configured();
75+
expect(() => strategy.decrypt('enc:v1:')).toThrow(/not a well-formed ciphertext/);
76+
});
6177
});

packages/core/src/entity/value-transformers.spec.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { REDACTED_SECRET_PLACEHOLDER } from '@vendure/common/lib/shared-constants';
12
import { describe, expect, it } from 'vitest';
23

34
import { EncryptionStrategy } from '../config/system/encryption-strategy';
@@ -6,7 +7,8 @@ import { EncryptedFieldTransformer } from './value-transformers';
67

78
/**
89
* A strategy that throws if asked to decrypt anything not produced by encrypt(), modelling a custom
9-
* strategy that honours the interface contract strictly (decrypt only handles ciphertext).
10+
* strategy that honours the interface contract strictly (decrypt only handles ciphertext). A value of
11+
* `enc:corrupt` models ciphertext that cannot be decrypted (e.g. corrupted or wrong-key data).
1012
*/
1113
class StrictStrategy implements EncryptionStrategy {
1214
encrypt(plaintext: string) {
@@ -16,7 +18,11 @@ class StrictStrategy implements EncryptionStrategy {
1618
if (!this.isEncrypted(ciphertext)) {
1719
throw new Error('decrypt() called on a non-ciphertext value');
1820
}
19-
return ciphertext.slice('enc:'.length);
21+
const payload = ciphertext.slice('enc:'.length);
22+
if (payload === 'corrupt') {
23+
throw new Error('cannot decrypt');
24+
}
25+
return payload;
2026
}
2127
isEncrypted(value: string) {
2228
return value.startsWith('enc:');
@@ -29,6 +35,15 @@ class StrictStrategy implements EncryptionStrategy {
2935
describe('EncryptedFieldTransformer', () => {
3036
const transformer = new EncryptedFieldTransformer(() => new StrictStrategy());
3137

38+
it('encrypts on write', () => {
39+
expect(transformer.to('secret')).toBe('enc:secret');
40+
});
41+
42+
it('passes null and empty through on write without encrypting', () => {
43+
expect(transformer.to(null)).toBe(null);
44+
expect(transformer.to('')).toBe('');
45+
});
46+
3247
it('decrypts ciphertext on read', () => {
3348
expect(transformer.from('enc:secret')).toBe('secret');
3449
});
@@ -37,8 +52,18 @@ describe('EncryptedFieldTransformer', () => {
3752
expect(transformer.from('legacy-plaintext')).toBe('legacy-plaintext');
3853
});
3954

40-
it('passes null and empty through', () => {
55+
it('returns the placeholder (not throwing) when a single value cannot be decrypted', () => {
56+
expect(transformer.from('enc:corrupt')).toBe(REDACTED_SECRET_PLACEHOLDER);
57+
});
58+
59+
it('passes null and empty through on read', () => {
4160
expect(transformer.from(null)).toBe(null);
4261
expect(transformer.from('')).toBe('');
4362
});
63+
64+
it('throws when no EncryptionStrategy is configured', () => {
65+
const unconfigured = new EncryptedFieldTransformer(() => undefined);
66+
expect(() => unconfigured.to('secret')).toThrow(/no EncryptionStrategy is configured/);
67+
expect(() => unconfigured.from('enc:secret')).toThrow(/no EncryptionStrategy is configured/);
68+
});
4469
});

0 commit comments

Comments
 (0)