147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
|
|
import { describe, it, expect, vi } from 'vitest';
|
||
|
|
import { SecretMigrateService } from '../src/services/secret-migrate.service.js';
|
||
|
|
import type { Secret, SecretBackend } from '@prisma/client';
|
||
|
|
|
||
|
|
function makeSecret(overrides: Partial<Secret>): Secret {
|
||
|
|
return {
|
||
|
|
id: 'sec',
|
||
|
|
name: 'sec',
|
||
|
|
backendId: 'b-plain',
|
||
|
|
data: {} as Secret['data'],
|
||
|
|
externalRef: '',
|
||
|
|
version: 1,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeBackend(overrides: Partial<SecretBackend>): SecretBackend {
|
||
|
|
return {
|
||
|
|
id: overrides.id ?? 'b',
|
||
|
|
name: overrides.name ?? 'b',
|
||
|
|
type: overrides.type ?? 'plaintext',
|
||
|
|
config: overrides.config ?? ({} as SecretBackend['config']),
|
||
|
|
tokenMeta: overrides.tokenMeta ?? ({} as SecretBackend['tokenMeta']),
|
||
|
|
isDefault: overrides.isDefault ?? false,
|
||
|
|
description: overrides.description ?? '',
|
||
|
|
version: 1,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build a service with in-memory state. Mocks the secret repo + backend
|
||
|
|
* service enough to exercise the bootstrap-token filter.
|
||
|
|
*/
|
||
|
|
function makeService(state: {
|
||
|
|
sourceName: string;
|
||
|
|
destName: string;
|
||
|
|
sourceBackend: SecretBackend;
|
||
|
|
destBackend: SecretBackend;
|
||
|
|
allBackends: SecretBackend[];
|
||
|
|
secretsOnSource: Secret[];
|
||
|
|
}) {
|
||
|
|
const secretRepo = {
|
||
|
|
findByBackend: vi.fn(async (backendId: string) => {
|
||
|
|
return state.secretsOnSource.filter((s) => s.backendId === backendId);
|
||
|
|
}),
|
||
|
|
update: vi.fn(async () => ({}) as Secret),
|
||
|
|
};
|
||
|
|
const backends = {
|
||
|
|
list: vi.fn(async () => state.allBackends),
|
||
|
|
getByName: vi.fn(async (name: string) => {
|
||
|
|
if (name === state.sourceBackend.name) return state.sourceBackend;
|
||
|
|
if (name === state.destBackend.name) return state.destBackend;
|
||
|
|
throw new Error(`unknown backend: ${name}`);
|
||
|
|
}),
|
||
|
|
driverFor: vi.fn(() => ({
|
||
|
|
read: vi.fn(async () => ({ k: 'v' })),
|
||
|
|
write: vi.fn(async () => ({ externalRef: 'ext', storedData: {} })),
|
||
|
|
delete: vi.fn(async () => {}),
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
return new SecretMigrateService(
|
||
|
|
secretRepo as never,
|
||
|
|
backends as never,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('SecretMigrateService — bootstrap-token protection', () => {
|
||
|
|
const sourceBackend = makeBackend({ id: 'b-plain', name: 'default', type: 'plaintext' });
|
||
|
|
const destBackend = makeBackend({
|
||
|
|
id: 'b-bao',
|
||
|
|
name: 'bao',
|
||
|
|
type: 'openbao',
|
||
|
|
config: { tokenSecretRef: { name: 'bao-creds', key: 'token' } } as unknown as SecretBackend['config'],
|
||
|
|
});
|
||
|
|
|
||
|
|
const secrets = [
|
||
|
|
makeSecret({ id: 's1', name: 'grafana-creds' }),
|
||
|
|
makeSecret({ id: 's2', name: 'bao-creds' }), // ← bootstrap token for `bao`
|
||
|
|
makeSecret({ id: 's3', name: 'ha-creds' }),
|
||
|
|
];
|
||
|
|
|
||
|
|
it('migrate() without --names skips the bootstrap-token secret', async () => {
|
||
|
|
const svc = makeService({
|
||
|
|
sourceName: 'default',
|
||
|
|
destName: 'bao',
|
||
|
|
sourceBackend, destBackend,
|
||
|
|
allBackends: [sourceBackend, destBackend],
|
||
|
|
secretsOnSource: secrets,
|
||
|
|
});
|
||
|
|
const result = await svc.migrate({ from: 'default', to: 'bao' });
|
||
|
|
|
||
|
|
const migratedNames = result.migrated.map((m) => m.name).sort();
|
||
|
|
expect(migratedNames).toEqual(['grafana-creds', 'ha-creds']);
|
||
|
|
|
||
|
|
const skippedBootstrap = result.skipped.find((s) => s.name === 'bao-creds');
|
||
|
|
expect(skippedBootstrap, 'bao-creds must be in skipped with a bootstrap-token reason').toBeDefined();
|
||
|
|
expect(skippedBootstrap!.reason).toMatch(/bootstrap token/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('migrate() WITH --names including the bootstrap token still migrates it (explicit override)', async () => {
|
||
|
|
const svc = makeService({
|
||
|
|
sourceName: 'default',
|
||
|
|
destName: 'bao',
|
||
|
|
sourceBackend, destBackend,
|
||
|
|
allBackends: [sourceBackend, destBackend],
|
||
|
|
secretsOnSource: secrets,
|
||
|
|
});
|
||
|
|
const result = await svc.migrate({ from: 'default', to: 'bao', names: ['bao-creds'] });
|
||
|
|
|
||
|
|
const migratedNames = result.migrated.map((m) => m.name);
|
||
|
|
expect(migratedNames).toEqual(['bao-creds']);
|
||
|
|
// No bootstrap-skip entry when the user explicitly asked for it
|
||
|
|
expect(result.skipped.find((s) => s.reason.includes('bootstrap'))).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('dryRun() matches migrate() — also filters bootstrap tokens by default', async () => {
|
||
|
|
const svc = makeService({
|
||
|
|
sourceName: 'default',
|
||
|
|
destName: 'bao',
|
||
|
|
sourceBackend, destBackend,
|
||
|
|
allBackends: [sourceBackend, destBackend],
|
||
|
|
secretsOnSource: secrets,
|
||
|
|
});
|
||
|
|
const candidates = await svc.dryRun({ from: 'default', to: 'bao' });
|
||
|
|
const names = candidates.map((c) => c.name).sort();
|
||
|
|
expect(names).toEqual(['grafana-creds', 'ha-creds']);
|
||
|
|
expect(names).not.toContain('bao-creds');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('dryRun() with --names reports exactly the named secrets', async () => {
|
||
|
|
const svc = makeService({
|
||
|
|
sourceName: 'default',
|
||
|
|
destName: 'bao',
|
||
|
|
sourceBackend, destBackend,
|
||
|
|
allBackends: [sourceBackend, destBackend],
|
||
|
|
secretsOnSource: secrets,
|
||
|
|
});
|
||
|
|
const candidates = await svc.dryRun({ from: 'default', to: 'bao', names: ['bao-creds', 'grafana-creds'] });
|
||
|
|
const names = candidates.map((c) => c.name).sort();
|
||
|
|
expect(names).toEqual(['bao-creds', 'grafana-creds']);
|
||
|
|
});
|
||
|
|
});
|