import { SECRET_SENTINEL, redactSecretConfig, restoreSecretConfig } from './redact-config'; import type { PluginConfigSchema } from '../../core/plugins/plugin.interfaces'; // plugin config (incl. fields a plugin marks `secret`, e.g. an API key) was returned // verbatim by the GET routes, readable by any key. Redact on read; restore on write so the // dashboard PUTting the masked value back doesn't overwrite the real secret. const schema: PluginConfigSchema = { type: 'object', properties: { apiKey: { type: 'string', secret: true }, endpoint: { type: 'string' }, }, }; describe('redactSecretConfig', () => { it('masks secret-flagged non-empty values, leaves non-secret fields intact', () => { expect(redactSecretConfig({ apiKey: 's3cr3t', endpoint: 'https://x' }, schema)).toEqual({ apiKey: SECRET_SENTINEL, endpoint: 'https://x', }); }); it('does not mask an empty/absent secret (so "***" never implies a secret that is not set)', () => { expect(redactSecretConfig({ apiKey: '', endpoint: 'https://x' }, schema)).toEqual({ apiKey: '', endpoint: 'https://x', }); expect(redactSecretConfig({ endpoint: 'https://x' }, schema)).toEqual({ endpoint: 'https://x' }); }); it('fails closed and masks every value when there is no schema (cannot tell which fields are secret)', () => { const cfg = { apiKey: 's3cr3t', endpoint: 'https://x' }; const out = redactSecretConfig(cfg, undefined); expect(out).toEqual({ apiKey: SECRET_SENTINEL, endpoint: SECRET_SENTINEL }); expect(out).not.toBe(cfg); // copy, not the same ref }); it('round-trips a schemaless config: sentinel values restore the stored value, edited values persist', () => { const masked = redactSecretConfig({ apiKey: 's3cr3t', region: 'us' }, undefined); const restored = restoreSecretConfig({ ...masked, region: 'eu' }, { apiKey: 's3cr3t', region: 'us' }, undefined); expect(restored).toEqual({ apiKey: 's3cr3t', region: 'eu' }); }); }); describe('restoreSecretConfig', () => { it('keeps the existing stored secret when the incoming value is the sentinel (unchanged round-trip)', () => { const merged = restoreSecretConfig( { apiKey: SECRET_SENTINEL, endpoint: 'https://new' }, { apiKey: 'real-secret' }, schema, ); expect(merged).toEqual({ apiKey: 'real-secret', endpoint: 'https://new' }); }); it('stores a genuinely new secret value', () => { const merged = restoreSecretConfig({ apiKey: 'brand-new' }, { apiKey: 'real-secret' }, schema); expect(merged.apiKey).toBe('brand-new'); }); it('drops a sentinel/empty secret when there is nothing stored to keep', () => { expect(restoreSecretConfig({ apiKey: SECRET_SENTINEL }, {}, schema)).not.toHaveProperty('apiKey'); expect(restoreSecretConfig({ apiKey: '' }, undefined, schema)).not.toHaveProperty('apiKey'); }); }); // v0.7 richer schema: secrets can live inside a nested object or in each row of an array-of-rows, // so redaction (read) and restoration (write) must recurse — else a nested secret leaks via GET, or // gets clobbered by the mask on the round-trip PUT. const nestedSchema: PluginConfigSchema = { type: 'object', properties: { provider: { type: 'object', properties: { apiKey: { type: 'string', secret: true }, region: { type: 'string' }, }, }, endpoints: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' }, token: { type: 'string', secret: true }, }, }, }, }, }; describe('redactSecretConfig (nested)', () => { it('masks a secret nested inside an object', () => { expect(redactSecretConfig({ provider: { apiKey: 'k', region: 'us' } }, nestedSchema)).toEqual({ provider: { apiKey: SECRET_SENTINEL, region: 'us' }, }); }); it('masks a secret sub-field in every row of an array-of-rows (leaving empty ones untouched)', () => { expect( redactSecretConfig( { endpoints: [ { url: 'a', token: 't1' }, { url: 'b', token: '' }, ], }, nestedSchema, ), ).toEqual({ endpoints: [ { url: 'a', token: SECRET_SENTINEL }, { url: 'b', token: '' }, ], }); }); }); describe('restoreSecretConfig (nested)', () => { it('restores a nested-object secret when the incoming value is the sentinel', () => { expect( restoreSecretConfig( { provider: { apiKey: SECRET_SENTINEL, region: 'eu' } }, { provider: { apiKey: 'real', region: 'us' } }, nestedSchema, ), ).toEqual({ provider: { apiKey: 'real', region: 'eu' } }); }); it('restores per-row array-of-rows secrets, keeping genuinely-new ones', () => { expect( restoreSecretConfig( { endpoints: [ { url: 'a', token: SECRET_SENTINEL }, { url: 'b', token: 'new' }, ], }, { endpoints: [ { url: 'a', token: 'real1' }, { url: 'b', token: 'real2' }, ], }, nestedSchema, ), ).toEqual({ endpoints: [ { url: 'a', token: 'real1' }, { url: 'b', token: 'new' }, ], }); }); // Rows are matched to the stored secret by their non-secret content, NOT by array index — so // adding/removing/reordering a row can never bind a sentinel to a different row's stored secret. it('restores by content after a row is removed (no positional drift)', () => { expect( restoreSecretConfig( { endpoints: [{ url: 'b', token: SECRET_SENTINEL }] }, { endpoints: [ { url: 'a', token: 'real_a' }, { url: 'b', token: 'real_b' }, ], }, nestedSchema, ), ).toEqual({ endpoints: [{ url: 'b', token: 'real_b' }] }); }); it('restores by content after rows are reordered', () => { expect( restoreSecretConfig( { endpoints: [ { url: 'b', token: SECRET_SENTINEL }, { url: 'a', token: SECRET_SENTINEL }, ], }, { endpoints: [ { url: 'a', token: 'real_a' }, { url: 'b', token: 'real_b' }, ], }, nestedSchema, ), ).toEqual({ endpoints: [ { url: 'b', token: 'real_b' }, { url: 'a', token: 'real_a' }, ], }); }); it('does not mis-target a secret onto a newly-inserted row', () => { // New row "NEW" has no stored counterpart → its sentinel is dropped (not filled with a's secret); // existing row "a" keeps its own secret regardless of the index shift. expect( restoreSecretConfig( { endpoints: [ { url: 'NEW', token: SECRET_SENTINEL }, { url: 'a', token: SECRET_SENTINEL }, ], }, { endpoints: [{ url: 'a', token: 'real1' }] }, nestedSchema, ), ).toEqual({ endpoints: [{ url: 'NEW' }, { url: 'a', token: 'real1' }], }); }); // A row's signature is the row with its secret masked, so editing a NON-secret field changes the // signature and the content-match misses. On an in-place edit (array length unchanged) the row at // the same index is the same logical row, so its stored secret must be preserved — losing it on a // routine rename is silent data loss. it('keeps a row secret when only its non-secret field changed (same length)', () => { expect( restoreSecretConfig( { endpoints: [{ url: 'a-renamed', token: SECRET_SENTINEL }] }, { endpoints: [{ url: 'a', token: 'real' }] }, nestedSchema, ), ).toEqual({ endpoints: [{ url: 'a-renamed', token: 'real' }] }); }); // Two rows with identical non-secret content collide on signature; on an unchanged round-trip both // secrets must survive (positional fallback), not be dropped to nothing. it('restores both secrets when two rows share non-secret content (no-op round-trip)', () => { expect( restoreSecretConfig( { endpoints: [ { url: 'a', token: SECRET_SENTINEL }, { url: 'a', token: SECRET_SENTINEL }, ], }, { endpoints: [ { url: 'a', token: 'real1' }, { url: 'a', token: 'real2' }, ], }, nestedSchema, ), ).toEqual({ endpoints: [ { url: 'a', token: 'real1' }, { url: 'a', token: 'real2' }, ], }); }); }); // An array whose ITEMS are scalar secrets (e.g. a list of API keys) — every masked element shares the // signature "***", so content-matching is impossible and restore must align by position. A no-op // round-trip or an in-place edit must never wipe the stored secrets to null. const scalarSecretArraySchema: PluginConfigSchema = { type: 'object', properties: { keys: { type: 'array', items: { type: 'string', secret: true } }, }, }; describe('restoreSecretConfig (scalar-secret array)', () => { it('restores every secret on an unchanged round-trip (no null pollution)', () => { expect( restoreSecretConfig( { keys: [SECRET_SENTINEL, SECRET_SENTINEL] }, { keys: ['k1', 'k2'] }, scalarSecretArraySchema, ), ).toEqual({ keys: ['k1', 'k2'] }); }); it('keeps a genuinely-new secret element while restoring the unchanged ones', () => { expect( restoreSecretConfig({ keys: [SECRET_SENTINEL, 'brand-new'] }, { keys: ['k1', 'k2'] }, scalarSecretArraySchema), ).toEqual({ keys: ['k1', 'brand-new'] }); }); it('drops a sentinel element that has nothing stored to keep (no null in the array)', () => { expect(restoreSecretConfig({ keys: [SECRET_SENTINEL] }, { keys: [] }, scalarSecretArraySchema)).toEqual({ keys: [], }); }); it('preserves stored secrets when a new key is appended (length grows)', () => { expect( restoreSecretConfig( { keys: [SECRET_SENTINEL, SECRET_SENTINEL, SECRET_SENTINEL, 'brand-new'] }, { keys: ['k1', 'k2', 'k3'] }, scalarSecretArraySchema, ), ).toEqual({ keys: ['k1', 'k2', 'k3', 'brand-new'] }); }); it('preserves stored secrets when a blank trailing entry is appended (the blank is dropped)', () => { expect( restoreSecretConfig( { keys: [SECRET_SENTINEL, SECRET_SENTINEL, SECRET_SENTINEL, ''] }, { keys: ['k1', 'k2', 'k3'] }, scalarSecretArraySchema, ), ).toEqual({ keys: ['k1', 'k2', 'k3'] }); }); it('preserves the remaining stored secrets when the last key is removed (length shrinks)', () => { expect( restoreSecretConfig( { keys: [SECRET_SENTINEL, SECRET_SENTINEL] }, { keys: ['k1', 'k2', 'k3'] }, scalarSecretArraySchema, ), ).toEqual({ keys: ['k1', 'k2'] }); }); }); // A composite field (object/array) that is itself marked secret:true must be masked as ONE unit on // read and restored as a unit on write — recursing would leak its non-secret children in plaintext. const compositeSecretSchema: PluginConfigSchema = { type: 'object', properties: { creds: { type: 'object', secret: true, properties: { user: { type: 'string' }, pass: { type: 'string' } } }, keys: { type: 'array', secret: true, items: { type: 'string' } }, }, }; describe('redactSecretConfig (composite secret field)', () => { it('masks a whole secret object so its children never leak', () => { expect(redactSecretConfig({ creds: { user: 'u', pass: 'p' } }, compositeSecretSchema)).toEqual({ creds: SECRET_SENTINEL, }); }); it('masks a whole secret array so its entries never leak', () => { expect(redactSecretConfig({ keys: ['k1', 'k2'] }, compositeSecretSchema)).toEqual({ keys: SECRET_SENTINEL }); }); }); describe('restoreSecretConfig (composite secret field)', () => { it('restores a secret object from the sentinel instead of clobbering it', () => { expect( restoreSecretConfig({ creds: SECRET_SENTINEL }, { creds: { user: 'u', pass: 'p' } }, compositeSecretSchema), ).toEqual({ creds: { user: 'u', pass: 'p' } }); }); it('restores a secret array from the sentinel', () => { expect(restoreSecretConfig({ keys: SECRET_SENTINEL }, { keys: ['k1', 'k2'] }, compositeSecretSchema)).toEqual({ keys: ['k1', 'k2'], }); }); it('stores a genuinely-new secret object as a unit', () => { expect( restoreSecretConfig( { creds: { user: 'x', pass: 'y' } }, { creds: { user: 'u', pass: 'p' } }, compositeSecretSchema, ), ).toEqual({ creds: { user: 'x', pass: 'y' } }); }); it('drops a sentinel secret object when there is nothing stored', () => { expect(restoreSecretConfig({ creds: SECRET_SENTINEL }, {}, compositeSecretSchema)).not.toHaveProperty('creds'); }); });