UNPKG

kubricate

Version:

A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.

1,244 lines (1,085 loc) 37 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { PreparedEffect } from '@kubricate/core'; import { InMemoryConnector } from '../connectors/InMemoryConnector.js'; import { InMemoryProvider } from '../providers/InMemoryProvider.js'; import { SecretManager } from '../SecretManager.js'; import { SecretRegistry } from '../SecretRegistry.js'; import { SecretsOrchestrator } from './SecretsOrchestrator.js'; import type { SecretsOrchestratorOptions } from './types.js'; describe('SecretsOrchestrator', () => { let mockSecretManager: SecretManager; let mockProvider: any; let orchestrator: SecretsOrchestrator; let mockLogger: any; let options: SecretsOrchestratorOptions; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; mockProvider = { name: 'kubernetes', // This is critical secretType: 'Kubricate.InMemory', // Or your desired secretType allowMerge: true, getEffectIdentifier: (effect: PreparedEffect) => effect.value?.metadata?.name ?? 'no-id', prepare: vi.fn((name, value) => [ { providerName: 'kubernetes', type: 'kubectl', value: { kind: 'Secret', metadata: { name: 'test-secret', provider: 'kubernetes' }, data: { [name]: value }, }, }, ]), mergeSecrets: vi.fn((effects: PreparedEffect[]) => effects), }; mockSecretManager = { getSecrets: vi.fn(() => ({ DB_PASS: { name: 'DB_PASS', connector: 'env', provider: 'kubernetes' }, })), resolveConnector: vi.fn(() => ({ getWorkingDir: () => undefined, setWorkingDir: vi.fn(), load: vi.fn(() => Promise.resolve()), get: vi.fn(() => 's3cr3t'), })) as any, resolveProvider: vi.fn(() => mockProvider), } as any; options = { config: { stacks: { api: { getSecretManagers: () => ({ main: mockSecretManager, }), } as any, }, secret: { conflict: { strategies: { intraProvider: 'autoMerge', crossProvider: 'error', crossManager: 'error', }, }, secretSpec: mockSecretManager, }, }, logger: mockLogger, effectOptions: { workingDir: '/project' }, } satisfies SecretsOrchestratorOptions; orchestrator = SecretsOrchestrator.create(options); }); it('should validate secrets without errors', async () => { await expect(orchestrator.validate()).resolves.not.toThrow(); expect(mockSecretManager.resolveConnector).toHaveBeenCalled(); }); it('should apply and return prepared effects', async () => { const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].value.kind).toBe('Secret'); expect(mockProvider.prepare).toHaveBeenCalled(); expect(mockProvider.mergeSecrets).toHaveBeenCalled(); }); it('should cache provider in resolveProviderByName()', () => { const provider = orchestrator['resolveProviderByName']('kubernetes'); expect(provider).toBe(mockProvider); const cached = orchestrator['providerCache'].get('kubernetes'); expect(cached).toBe(mockProvider); }); it('should throw if provider name is not found', () => { expect(() => orchestrator['resolveProviderByName']('not-exist')).toThrowError( '[SecretsOrchestrator] Provider "not-exist" not found in any registered SecretManager' ); }); }); describe('SecretsOrchestrator Multi-Level Merge Strategy', () => { let mockLogger: any; let mockProvider: any; let mockSecretManager: (name: string, secrets: Record<string, any>) => SecretManager; let orchestrator: SecretsOrchestrator; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; mockProvider = { name: 'kubernetes', // This is critical secretType: 'Kubricate.InMemory', // Or your desired secretType allowMerge: true, getEffectIdentifier: (effect: PreparedEffect) => effect.value?.metadata?.name ?? 'no-id', prepare: vi.fn((name, value) => [ { providerName: 'kubernetes', type: 'kubectl', value: { kind: 'Secret', metadata: { name: 'merged-secret', provider: 'kubernetes' }, data: { [name]: value }, }, }, ]), mergeSecrets: vi.fn((effects: PreparedEffect[]) => effects), }; mockSecretManager = (name: string, secrets: Record<string, any>): SecretManager => ({ getSecrets: vi.fn(() => secrets), resolveConnector: vi.fn(() => ({ getWorkingDir: () => undefined, setWorkingDir: vi.fn(), load: vi.fn(() => Promise.resolve()), get: vi.fn(key => secrets[key].value), })), resolveProvider: vi.fn(() => mockProvider), }) as any; }); it('snapshots multiple secrets from the same manager', async () => { const _mockSecretManager = mockSecretManager('svc', { DB_USER: { connector: 'env', provider: 'kubernetes', value: 'admin' }, DB_PASS: { connector: 'env', provider: 'kubernetes', value: 's3cr3t' }, }); const stacks = { stack1: { getSecretManagers: () => ({ svc: _mockSecretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { conflict: { strategies: { intraProvider: 'autoMerge', crossProvider: 'autoMerge', crossManager: 'autoMerge', }, }, secretSpec: _mockSecretManager, }, }, }; orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toMatchSnapshot(); }); }); describe('SecretsOrchestrator Advanced Merge Tests', () => { let mockLogger: any; let mockProvider: any; let mockSecretManager: (name: string, secrets: Record<string, any>) => SecretManager; let orchestrator: SecretsOrchestrator; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; mockProvider = { name: 'kubernetes', // This is critical secretType: 'Kubricate.InMemory', // Or your desired secretType allowMerge: true, getEffectIdentifier: (effect: PreparedEffect) => effect.value?.metadata?.name ?? 'no-id', prepare: vi.fn((name, value) => [ { providerName: 'kubernetes', type: 'kubectl', value: { kind: 'Secret', metadata: { name: 'test', provider: 'kubernetes' }, data: { [name]: JSON.stringify(value) }, }, }, ]), mergeSecrets: vi.fn((effects: PreparedEffect[]) => effects), }; mockSecretManager = (name: string, secrets: Record<string, any>): SecretManager => ({ getSecrets: vi.fn(() => secrets), resolveConnector: vi.fn(() => ({ getWorkingDir: () => undefined, setWorkingDir: vi.fn(), load: vi.fn(() => Promise.resolve()), get: vi.fn(key => secrets[key].value), })), resolveProvider: vi.fn(() => mockProvider), }) as any; }); it('includes correct SecretOrigin metadata and snapshots effect', async () => { const _mockSecretManager = mockSecretManager('svc', { API_KEY: { connector: 'env', provider: 'kubernetes', value: '12345' }, }); const stacks = { stack1: { getSecretManagers: () => ({ svc: _mockSecretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { conflict: { strategies: { intraProvider: 'autoMerge', }, }, secretSpec: _mockSecretManager, }, }, }; orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toMatchSnapshot(); expect(mockProvider.prepare).toHaveBeenCalledWith('API_KEY', '12345'); }); }); describe('SecretsOrchestrator intraProvider (Integration Tests)', () => { let orchestrator: SecretsOrchestrator; let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should throw on intraProvider conflict with strategy "error"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'value1', SHARED_KEY_DUPLICATE: 'value2', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'secret-application' })) .addSecret({ name: 'SHARED_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'SHARED_KEY_DUPLICATE', provider: 'InMemoryProvider' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { intraProvider: 'error', // This will trigger the error }, }, }, }, }; orchestrator = SecretsOrchestrator.create(options); await expect(orchestrator.apply()).rejects.toThrowError(/\[conflict:error:intraProvider\]/); }); it('merges secrets within same provider when intraProvider is "autoMerge"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ API_KEY: 'abc123', API_SECRET: 'shhh', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'secret-application' })) .addSecret({ name: 'API_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'API_SECRET', provider: 'InMemoryProvider' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { intraProvider: 'autoMerge', }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].value.storeName).toBe('secret-application'); expect(effects[0].value.rawData).toEqual({ API_KEY: 'abc123', API_SECRET: 'shhh', }); }); it('overwrites previous secret when intraProvider is "overwrite"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'value1', SHARED_KEY_DUPLICATE: 'value2', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'secret-application' })) .addSecret({ name: 'SHARED_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'SHARED_KEY_DUPLICATE', provider: 'InMemoryProvider' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { intraProvider: 'overwrite', }, }, }, }, }; orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); const effect = effects[0]; // Should only contain last key (SHARED_KEY_DUPLICATE) expect(effect.value.rawData).toEqual({ SHARED_KEY_DUPLICATE: 'value2', }); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:intraProvider]')); }); it('keeps last secret and logs dropped in overwrite strategy (intraProvider)', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'v1', SHARED_KEY_DUPLICATE: 'v2', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'secret-app' })) .addSecret({ name: 'SHARED_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'SHARED_KEY_DUPLICATE', provider: 'InMemoryProvider' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { intraProvider: 'overwrite' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:intraProvider]')); }); }); describe('SecretsOrchestrator crossProvider (Integration Tests)', () => { let orchestrator: SecretsOrchestrator; let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should throw on intraProvider conflict with strategy "error"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ my_app_key: 'my_app_key_value', my_app_key_2: 'my_app_key_2_value', }) ) .addProvider( 'InMemoryProvider1', new InMemoryProvider({ name: 'secret-application', }) ) .addProvider( 'InMemoryProvider2', new InMemoryProvider({ name: 'secret-application', }) ) .setDefaultProvider('InMemoryProvider1') .addSecret({ name: 'my_app_key', provider: 'InMemoryProvider1' }) .addSecret({ name: 'my_app_key_2', provider: 'InMemoryProvider2' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { crossProvider: 'error', }, }, }, }, }; orchestrator = SecretsOrchestrator.create(options); await expect(orchestrator.apply()).rejects.toThrowError(/\[conflict:error:crossProvider\]/); }); it('merges secrets across providers with same storeName when crossProvider is "autoMerge"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ my_app_key: 'my_app_key_value', my_app_key_2: 'my_app_key_2_value', }) ) .addProvider( 'InMemoryProvider1', new InMemoryProvider({ name: 'secret-application', }) ) .addProvider( 'InMemoryProvider2', new InMemoryProvider({ name: 'secret-application', }) ) .setDefaultProvider('InMemoryProvider1') .addSecret({ name: 'my_app_key', provider: 'InMemoryProvider1' }) .addSecret({ name: 'my_app_key_2', provider: 'InMemoryProvider2' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { crossProvider: 'autoMerge', }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); // ✅ only one merged result const effect = effects[0]; expect(effect.providerName).toBe('InMemoryProvider1'); // or InMemoryProvider2, doesn't matter expect(effect.value.storeName).toBe('secret-application'); expect(effect.value.rawData).toEqual({ my_app_key: 'my_app_key_value', my_app_key_2: 'my_app_key_2_value', }); }); it('overwrites previous secret when crossProvider is "overwrite"', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'one', SHARED_KEY_2: 'two', }) ) .addProvider('InMemoryProvider1', new InMemoryProvider({ name: 'secret-application' })) .addProvider('InMemoryProvider2', new InMemoryProvider({ name: 'secret-application' })) .setDefaultProvider('InMemoryProvider1') .addSecret({ name: 'SHARED_KEY', provider: 'InMemoryProvider1' }) .addSecret({ name: 'SHARED_KEY_2', provider: 'InMemoryProvider2' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { crossProvider: 'overwrite' }, }, }, }, }; orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].value.rawData).toEqual({ SHARED_KEY_2: 'two', }); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:crossProvider]')); }); it('keeps last secret and logs dropped in overwrite strategy (crossProvider)', async () => { const secretManager = new SecretManager() .addConnector( 'InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'v1', SHARED_KEY_DUPLICATE: 'v2', }) ) .addProvider('InMemoryProvider1', new InMemoryProvider({ name: 'secret-app' })) .addProvider('InMemoryProvider2', new InMemoryProvider({ name: 'secret-app' })) .setDefaultProvider('InMemoryProvider1') .addSecret({ name: 'SHARED_KEY', provider: 'InMemoryProvider1' }) .addSecret({ name: 'SHARED_KEY_DUPLICATE', provider: 'InMemoryProvider2' }) .build(); const stacks = { app: { getSecretManagers: () => ({ default: secretManager }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretManager, conflict: { strategies: { crossProvider: 'overwrite' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:crossProvider]')); }); }); describe('SecretsOrchestrator crossManager (Integration Tests)', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should throw on crossManager conflict with strategy "error"', async () => { const svc1 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'A' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared' })) .addSecret({ name: 'KEY', provider: 'InMemoryProvider' }) .build(); const svc2 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'B' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared' })) .addSecret({ name: 'KEY', provider: 'InMemoryProvider' }) .build(); const stack = { getSecretManagers: () => ({ svc1, svc2, }), }; const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: { app: stack as any }, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'error' as const }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); await expect(orchestrator.apply()).rejects.toThrowError(/\[conflict:error:crossManager]/); }); it('merges on crossManager conflict with strategy "autoMerge"', async () => { const svc1 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY1: 'A' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared' })) .addSecret({ name: 'KEY1', provider: 'InMemoryProvider' }) .build(); const svc2 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY2: 'B' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared' })) .addSecret({ name: 'KEY2', provider: 'InMemoryProvider' }) .build(); const stack = { getSecretManagers: () => ({ svc1, svc2, }), }; const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: { app: stack as any }, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'autoMerge' as const }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].value.rawData).toEqual({ KEY1: 'A', KEY2: 'B', }); }); it('should overwrite secret when conflict occurs within the same SecretManager (crossManager)', async () => { const a = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'one' })) .addProvider('P', new InMemoryProvider({ name: 'stack-secret' })) .addSecret({ name: 'SHARED_KEY', provider: 'P' }) .build(); const b = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ SHARED_KEY: 'two' })) .addProvider('P', new InMemoryProvider({ name: 'stack-secret' })) .addSecret({ name: 'SHARED_KEY', provider: 'P' }) .build(); const stacks = { app: { getSecretManagers: () => ({ a, b, }), }, }; const secretRegistry = new SecretRegistry().add('a', a).add('b', b); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'overwrite' }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); }); it('merges same identifier across different SecretManagers with strategy "overwrite"', async () => { const svc1 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'one' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-app' })) .addSecret({ name: 'KEY' }) .build(); const svc2 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'two' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-app' })) .addSecret({ name: 'KEY' }) .build(); const stacks = { app: { getSecretManagers: () => ({ svc1, svc2, }), }, }; const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'overwrite' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:crossManager]')); }); }); describe('SecretsOrchestrator crossManager (Integration Tests)', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('throws conflict when same identifier across managers and strategy = "error"', async () => { const svc1 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY: 'value1' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const svc2 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY: 'value2' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: { app: { getSecretManagers: () => ({ svc1, svc2 }) } as any }, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'error' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); await expect(orchestrator.apply()).rejects.toThrowError(/\[conflict:error:crossManager]/); }); it('overwrites when same identifier across managers and strategy = "overwrite"', async () => { const svc1 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY: 'value1' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const svc2 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY: 'value2' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: { app: { getSecretManagers: () => ({ svc1, svc2 }) } as any }, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'overwrite' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('[conflict:overwrite:crossManager]')); }); it('auto-merges when different keys across managers and strategy = "autoMerge"', async () => { const svc1 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY1: 'one' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY1' }) .build(); const svc2 = new SecretManager() .addConnector('InMemory', new InMemoryConnector({ KEY2: 'two' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY2' }) .build(); const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger, effectOptions: {}, config: { stacks: { app: { getSecretManagers: () => ({ svc1, svc2 }) } as any }, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'autoMerge' } }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].value.rawData).toEqual({ KEY1: 'one', KEY2: 'two' }); }); }); describe('SecretsOrchestrator cross-stack using single SecretManager (Integration)', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should not conflict when multiple stacks share the same SecretManager', async () => { const sharedManager = new SecretManager() .addConnector( 'InMemoryLoader', new InMemoryConnector({ API_KEY: 'abc123', DB_PASSWORD: 'secretpw', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'app-secret' })) .setDefaultProvider('InMemoryProvider') .addSecret({ name: 'API_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'DB_PASSWORD', provider: 'InMemoryProvider' }) .build(); const stacks = { frontend: { getSecretManagers: () => ({ default: sharedManager, }), }, backend: { getSecretManagers: () => ({ default: sharedManager, }), }, }; const options = { logger: mockLogger, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: sharedManager, }, }, }; const orchestrator = SecretsOrchestrator.create(options); const effects = await orchestrator.apply(); expect(effects).toHaveLength(1); expect(effects[0].providerName).toBe('InMemoryProvider'); expect(effects[0].value.rawData).toEqual({ API_KEY: 'abc123', DB_PASSWORD: 'secretpw', }); // Should not log any conflict or error expect(mockLogger.warn).not.toHaveBeenCalled(); }); }); describe('SecretsOrchestrator strictConflictMode (Negative Test)', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should throw if strictConflictMode=true but conflict strategies are relaxed', async () => { const sharedManager = new SecretManager() .addConnector( 'InMemoryLoader', new InMemoryConnector({ API_KEY: 'abc123', DB_PASSWORD: 'secretpw', }) ) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'app-secret' })) .setDefaultProvider('InMemoryProvider') .addSecret({ name: 'API_KEY', provider: 'InMemoryProvider' }) .addSecret({ name: 'DB_PASSWORD', provider: 'InMemoryProvider' }) .build(); const stacks = { frontend: { getSecretManagers: () => ({ default: sharedManager, }), }, backend: { getSecretManagers: () => ({ default: sharedManager, }), }, }; const options: SecretsOrchestratorOptions = { logger: mockLogger as any, effectOptions: {}, config: { stacks: stacks as any, secret: { manager: sharedManager, conflict: { strict: true, strategies: { intraProvider: 'autoMerge', // ❌ should be error when strictConflictMode = true }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); // 💥 Expect an error thrown during validation, not apply phase await expect(orchestrator.apply()).rejects.toThrowError(/\[config:strictConflictMode\]/); }); }); describe('SecretsOrchestrator Cross-Manager Conflict Detection', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should detect conflict between different managers when strategy is error (needs managerName in conflictKey)', async () => { const svc1 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'one' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const svc2 = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ KEY: 'two' })) .addProvider('InMemoryProvider', new InMemoryProvider({ name: 'shared-secret' })) .addSecret({ name: 'KEY' }) .build(); const stacks = { app: { getSecretManagers: () => ({ svc1, svc2, }), }, }; const secretRegistry = new SecretRegistry().add('svc1', svc1).add('svc2', svc2); const options: SecretsOrchestratorOptions = { logger: mockLogger as any, effectOptions: {}, config: { stacks: stacks as any, secret: { secretSpec: secretRegistry, conflict: { strategies: { crossManager: 'error', // ❗ สำคัญ }, }, }, }, }; const orchestrator = SecretsOrchestrator.create(options); await expect(orchestrator.apply()).rejects.toThrowError( /\[conflict:error:crossManager] Duplicate resource identifier/ ); }); }); describe('SecretsOrchestrator - Multiple SecretManagers with Same Provider Name', () => { let mockLogger: any; beforeEach(() => { mockLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; }); it('should apply secrets correctly without conflict', async () => { // Arrange - Setup SecretManagers and Registry manually const frontendSecretManager = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ frontend_app_key: 'value1' })) .addProvider('OpaqueSecretProvider', new InMemoryProvider({ name: 'secret-frontend' })) .addSecret({ name: 'frontend_app_key', }); const backendSecretManager = new SecretManager() .addConnector('InMemoryConnector', new InMemoryConnector({ backend_app_key: 'value2' })) .addProvider('OpaqueSecretProvider', new InMemoryProvider({ name: 'secret-backend' })) .addSecret({ name: 'backend_app_key', }); const secretRegistry = new SecretRegistry() .add('frontend', frontendSecretManager) .add('backend', backendSecretManager); const orchestrator = SecretsOrchestrator.create({ effectOptions: {}, config: { secret: { secretSpec: secretRegistry, }, stacks: {}, }, logger: mockLogger as any, // or mock logger if you want }); // Act const effects = await orchestrator.apply(); // Assert expect(effects).toHaveLength(2); const metadata = effects.map(effect => { return effect.value; }); expect(metadata).toStrictEqual([ { rawData: { frontend_app_key: 'value1', }, storeName: 'secret-frontend', }, { rawData: { backend_app_key: 'value2', }, storeName: 'secret-backend', }, ]); }); });