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 { 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 { 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']); }); });