kubricate
Version:
A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.
1,244 lines (1,085 loc) • 37 kB
text/typescript
/* 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',
},
]);
});
});