|
| 1 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | + |
| 3 | +// The EQL-v3 lifecycle branches (#648/#649): v3 has no cut-over rename — |
| 4 | +// `encrypt cutover` must short-circuit with guidance instead of running the |
| 5 | +// v2 config machine, and `encrypt drop` must target the ORIGINAL plaintext |
| 6 | +// column (there is no `<col>_plaintext`) gated on `backfilled` rather than |
| 7 | +// `cut-over`. The v2 paths are pinned alongside as regression guards. |
| 8 | + |
| 9 | +const queryMock = vi.hoisted(() => vi.fn(async () => ({ rows: [] }))) |
| 10 | +vi.mock('pg', () => ({ |
| 11 | + default: { |
| 12 | + Client: class { |
| 13 | + connect = vi.fn(async () => {}) |
| 14 | + end = vi.fn(async () => {}) |
| 15 | + query = queryMock |
| 16 | + }, |
| 17 | + }, |
| 18 | +})) |
| 19 | + |
| 20 | +const migrateMocks = vi.hoisted(() => ({ |
| 21 | + detectColumnEqlVersion: vi.fn(async () => 'v3' as 'v2' | 'v3' | null), |
| 22 | + progress: vi.fn( |
| 23 | + async () => ({ phase: 'backfilled' }) as { phase: string } | null, |
| 24 | + ), |
| 25 | + appendEvent: vi.fn(async () => {}), |
| 26 | + setManifestTargetPhase: vi.fn(async () => {}), |
| 27 | + renameEncryptedColumns: vi.fn(async () => {}), |
| 28 | + migrateConfig: vi.fn(async () => {}), |
| 29 | + activateConfig: vi.fn(async () => {}), |
| 30 | + reloadConfig: vi.fn(async () => {}), |
| 31 | +})) |
| 32 | +vi.mock('@cipherstash/migrate', () => migrateMocks) |
| 33 | + |
| 34 | +vi.mock('@clack/prompts', () => ({ |
| 35 | + intro: vi.fn(), |
| 36 | + outro: vi.fn(), |
| 37 | + confirm: vi.fn(async () => true), |
| 38 | + isCancel: vi.fn(() => false), |
| 39 | + log: { |
| 40 | + info: vi.fn(), |
| 41 | + success: vi.fn(), |
| 42 | + warn: vi.fn(), |
| 43 | + error: vi.fn(), |
| 44 | + step: vi.fn(), |
| 45 | + }, |
| 46 | + note: vi.fn(), |
| 47 | +})) |
| 48 | +vi.mock('@/config/index.js', () => ({ |
| 49 | + loadStashConfig: vi.fn(async () => ({ databaseUrl: 'postgres://test' })), |
| 50 | +})) |
| 51 | +vi.mock('@/commands/db/detect.js', () => ({ |
| 52 | + detectDrizzle: vi.fn(() => false), |
| 53 | +})) |
| 54 | +vi.mock('@/commands/init/utils.js', () => ({ |
| 55 | + detectPackageManager: vi.fn(() => 'npm'), |
| 56 | + runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`), |
| 57 | +})) |
| 58 | +const scaffoldMock = vi.hoisted(() => |
| 59 | + vi.fn(async ({ name }: { name: string }) => ({ |
| 60 | + path: `drizzle/${name}.sql`, |
| 61 | + })), |
| 62 | +) |
| 63 | +vi.mock('../drizzle-helper.js', () => ({ |
| 64 | + scaffoldDrizzleMigration: scaffoldMock, |
| 65 | +})) |
| 66 | +const writeFileMock = vi.hoisted(() => vi.fn()) |
| 67 | +vi.mock('node:fs', () => ({ |
| 68 | + default: { mkdirSync: vi.fn(), writeFileSync: writeFileMock }, |
| 69 | +})) |
| 70 | + |
| 71 | +import * as p from '@clack/prompts' |
| 72 | +import { cutoverCommand } from '../cutover.js' |
| 73 | +import { dropCommand } from '../drop.js' |
| 74 | + |
| 75 | +describe('encrypt cutover — EQL version awareness', () => { |
| 76 | + beforeEach(() => { |
| 77 | + vi.clearAllMocks() |
| 78 | + migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' }) |
| 79 | + }) |
| 80 | + |
| 81 | + it('short-circuits on a v3 column: guidance, no rename, no config machine', async () => { |
| 82 | + migrateMocks.detectColumnEqlVersion.mockResolvedValueOnce('v3') |
| 83 | + |
| 84 | + await cutoverCommand({ table: 'users', column: 'email' }) |
| 85 | + |
| 86 | + expect(p.log.info).toHaveBeenCalledWith( |
| 87 | + expect.stringContaining('not applicable to EQL v3'), |
| 88 | + ) |
| 89 | + expect(p.log.info).toHaveBeenCalledWith( |
| 90 | + expect.stringContaining('stash encrypt drop'), |
| 91 | + ) |
| 92 | + expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled() |
| 93 | + expect(migrateMocks.migrateConfig).not.toHaveBeenCalled() |
| 94 | + expect(migrateMocks.activateConfig).not.toHaveBeenCalled() |
| 95 | + expect(migrateMocks.appendEvent).not.toHaveBeenCalled() |
| 96 | + }) |
| 97 | + |
| 98 | + it('still runs the v2 flow for a v2 column (regression pin)', async () => { |
| 99 | + migrateMocks.detectColumnEqlVersion.mockResolvedValueOnce('v2') |
| 100 | + // pending-config check returns true |
| 101 | + queryMock.mockImplementation(async (sql: string) => |
| 102 | + typeof sql === 'string' && sql.includes('eql_v2_configuration') |
| 103 | + ? { rows: [{ exists: true }] } |
| 104 | + : { rows: [] }, |
| 105 | + ) |
| 106 | + |
| 107 | + await cutoverCommand({ table: 'users', column: 'email' }) |
| 108 | + |
| 109 | + expect(migrateMocks.renameEncryptedColumns).toHaveBeenCalled() |
| 110 | + expect(migrateMocks.migrateConfig).toHaveBeenCalled() |
| 111 | + expect(migrateMocks.activateConfig).toHaveBeenCalled() |
| 112 | + }) |
| 113 | +}) |
| 114 | + |
| 115 | +describe('encrypt drop — EQL version awareness', () => { |
| 116 | + beforeEach(() => { |
| 117 | + vi.clearAllMocks() |
| 118 | + }) |
| 119 | + |
| 120 | + it('v3: requires phase backfilled and drops the ORIGINAL column', async () => { |
| 121 | + migrateMocks.detectColumnEqlVersion.mockResolvedValueOnce('v3') |
| 122 | + migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' }) |
| 123 | + |
| 124 | + await dropCommand({ table: 'users', column: 'email' }) |
| 125 | + |
| 126 | + // Non-drizzle fallback writes the SQL file — inspect the generated DDL. |
| 127 | + const sql = writeFileMock.mock.calls[0]?.[1] as string |
| 128 | + expect(sql).toContain('DROP COLUMN "email"') |
| 129 | + expect(sql).not.toContain('email_plaintext') |
| 130 | + expect(migrateMocks.appendEvent).toHaveBeenCalledWith( |
| 131 | + expect.anything(), |
| 132 | + expect.objectContaining({ event: 'dropped', phase: 'dropped' }), |
| 133 | + ) |
| 134 | + }) |
| 135 | + |
| 136 | + it('v3: rejects when not yet backfilled', async () => { |
| 137 | + migrateMocks.detectColumnEqlVersion.mockResolvedValueOnce('v3') |
| 138 | + migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilling' }) |
| 139 | + const exitSpy = vi |
| 140 | + .spyOn(process, 'exit') |
| 141 | + .mockImplementation((() => undefined) as never) |
| 142 | + |
| 143 | + await dropCommand({ table: 'users', column: 'email' }) |
| 144 | + |
| 145 | + expect(p.log.error).toHaveBeenCalledWith( |
| 146 | + expect.stringContaining("Must be 'backfilled'"), |
| 147 | + ) |
| 148 | + expect(writeFileMock).not.toHaveBeenCalled() |
| 149 | + expect(exitSpy).toHaveBeenCalledWith(1) |
| 150 | + exitSpy.mockRestore() |
| 151 | + }) |
| 152 | + |
| 153 | + it('v2: unchanged — requires cut-over and drops <col>_plaintext (regression pin)', async () => { |
| 154 | + migrateMocks.detectColumnEqlVersion.mockResolvedValueOnce('v2') |
| 155 | + migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' }) |
| 156 | + |
| 157 | + await dropCommand({ table: 'users', column: 'email' }) |
| 158 | + |
| 159 | + const sql = writeFileMock.mock.calls[0]?.[1] as string |
| 160 | + expect(sql).toContain('DROP COLUMN "email_plaintext"') |
| 161 | + }) |
| 162 | +}) |
0 commit comments