@@ -5,9 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55// v2 config machine, and `encrypt drop` must target the ORIGINAL plaintext
66// column (there is no `<col>_plaintext`) gated on `backfilled` AND a live
77// coverage check. Version + encrypted-column NAME come from the domain types
8- // via `resolveEncryptedColumn` — the `<col>_encrypted` naming is a
9- // convention, never relied upon. The v2 paths are pinned alongside as
10- // regression guards.
8+ // via `resolveColumnLifecycle` — the `<col>_encrypted` naming is a
9+ // convention, never relied upon — and both commands FAIL CLOSED when
10+ // resolution is ambiguous instead of guessing a lifecycle. The v2 paths are
11+ // pinned alongside as regression guards.
1112
1213const queryMock = vi . hoisted ( ( ) =>
1314 vi . fn ( async ( _sql : string ) => ( { rows : [ ] as unknown [ ] } ) ) ,
@@ -23,16 +24,12 @@ vi.mock('pg', () => ({
2324} ) )
2425
2526type ColumnInfo = { column : string ; domain : string ; version : 2 | 3 }
27+ type ResolvedInfo = ColumnInfo & { via : 'hint' | 'convention' | 'sole' }
28+ type Lifecycle = { info : ResolvedInfo | null ; candidates : ColumnInfo [ ] }
2629
2730const migrateMocks = vi . hoisted ( ( ) => ( {
28- resolveEncryptedColumn : vi . fn (
29- async ( ) : Promise < ColumnInfo | null > => ( {
30- column : 'email_encrypted' ,
31- domain : 'eql_v3_text_search' ,
32- version : 3 ,
33- } ) ,
34- ) ,
3531 listEncryptedColumns : vi . fn ( async ( ) : Promise < ColumnInfo [ ] > => [ ] ) ,
32+ pickEncryptedColumn : vi . fn ( ( ) => null ) ,
3633 readManifest : vi . fn ( async ( ) => null ) ,
3734 countUnencrypted : vi . fn ( async ( ) => 0 ) ,
3835 progress : vi . fn (
@@ -47,6 +44,22 @@ const migrateMocks = vi.hoisted(() => ({
4744} ) )
4845vi . mock ( '@cipherstash/migrate' , ( ) => migrateMocks )
4946
47+ // Mock the lifecycle RESOLUTION (each test states its scenario directly)
48+ // but keep the real `explainUnresolved` — the fail-closed messaging is part
49+ // of what these tests pin.
50+ const lifecycleMock = vi . hoisted ( ( ) =>
51+ vi . fn (
52+ async ( ) : Promise < {
53+ info : ( ColumnInfo & { via : string } ) | null
54+ candidates : ColumnInfo [ ]
55+ } > => ( { info : null , candidates : [ ] } ) ,
56+ ) ,
57+ )
58+ vi . mock ( '../lib/resolve-eql.js' , async ( importOriginal ) => {
59+ const actual = await importOriginal < typeof import ( '../lib/resolve-eql.js' ) > ( )
60+ return { ...actual , resolveColumnLifecycle : lifecycleMock }
61+ } )
62+
5063vi . mock ( '@clack/prompts' , ( ) => ( {
5164 intro : vi . fn ( ) ,
5265 outro : vi . fn ( ) ,
@@ -104,6 +117,13 @@ const V2_INFO: ColumnInfo = {
104117 version : 2 ,
105118}
106119
120+ function resolved (
121+ info : ColumnInfo ,
122+ via : ResolvedInfo [ 'via' ] = 'convention' ,
123+ ) : Lifecycle {
124+ return { info : { ...info , via } , candidates : [ info ] }
125+ }
126+
107127/** v2 config machine present + a pending config row. */
108128function mockV2ConfigQueries ( ) {
109129 queryMock . mockImplementation ( async ( sql : string ) => {
@@ -129,7 +149,7 @@ describe('encrypt cutover — EQL version awareness', () => {
129149 vi . clearAllMocks ( )
130150 queryMock . mockResolvedValue ( { rows : [ ] } )
131151 migrateMocks . progress . mockResolvedValue ( { phase : 'backfilled' } )
132- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V3_INFO )
152+ lifecycleMock . mockResolvedValue ( resolved ( V3_INFO ) )
133153 } )
134154
135155 it ( 'short-circuits on a backfilled v3 column: guidance, no rename, no config machine, exit 0' , async ( ) => {
@@ -169,8 +189,22 @@ describe('encrypt cutover — EQL version awareness', () => {
169189 exitSpy . mockRestore ( )
170190 } )
171191
192+ it ( 'v3 already dropped: terminal phase is "nothing to do", not "finish the backfill"; exit 0' , async ( ) => {
193+ migrateMocks . progress . mockResolvedValue ( { phase : 'dropped' } )
194+ const exitSpy = spyExit ( )
195+
196+ await cutoverCommand ( { table : 'users' , column : 'email' } )
197+
198+ expect ( p . log . info ) . toHaveBeenCalledWith (
199+ expect . stringContaining ( 'already completed' ) ,
200+ )
201+ expect ( p . log . error ) . not . toHaveBeenCalled ( )
202+ expect ( exitSpy ) . not . toHaveBeenCalled ( )
203+ exitSpy . mockRestore ( )
204+ } )
205+
172206 it ( 'uses the RESOLVED encrypted column name, not the naming convention' , async ( ) => {
173- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V3_CUSTOM_INFO )
207+ lifecycleMock . mockResolvedValue ( resolved ( V3_CUSTOM_INFO , 'hint' ) )
174208
175209 await cutoverCommand ( { table : 'users' , column : 'email' } )
176210
@@ -182,8 +216,29 @@ describe('encrypt cutover — EQL version awareness', () => {
182216 )
183217 } )
184218
219+ it ( 'fails closed when EQL columns exist but none is identifiable' , async ( ) => {
220+ lifecycleMock . mockResolvedValue ( {
221+ info : null ,
222+ candidates : [
223+ { column : 'a_enc' , domain : 'eql_v3_text_eq' , version : 3 } ,
224+ { column : 'b_enc' , domain : 'eql_v3_text_eq' , version : 3 } ,
225+ ] ,
226+ } )
227+ const exitSpy = spyExit ( )
228+
229+ await cutoverCommand ( { table : 'users' , column : 'email' } )
230+
231+ expect ( p . log . error ) . toHaveBeenCalledWith (
232+ expect . stringContaining ( 'Cannot identify which encrypted column' ) ,
233+ )
234+ expect ( p . log . error ) . toHaveBeenCalledWith ( expect . stringContaining ( 'a_enc' ) )
235+ expect ( migrateMocks . renameEncryptedColumns ) . not . toHaveBeenCalled ( )
236+ expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
237+ exitSpy . mockRestore ( )
238+ } )
239+
185240 it ( 'still runs the v2 flow for a v2 column (regression pin)' , async ( ) => {
186- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V2_INFO )
241+ lifecycleMock . mockResolvedValue ( resolved ( V2_INFO ) )
187242 mockV2ConfigQueries ( )
188243
189244 await cutoverCommand ( { table : 'users' , column : 'email' } )
@@ -196,8 +251,7 @@ describe('encrypt cutover — EQL version awareness', () => {
196251 it ( 'explains a v3-only database instead of a raw relation-does-not-exist error' , async ( ) => {
197252 // Detection missed (e.g. no EQL columns visible) → v2 path — but the
198253 // config table doesn't exist on this database.
199- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( null )
200- migrateMocks . listEncryptedColumns . mockResolvedValue ( [ ] )
254+ lifecycleMock . mockResolvedValue ( { info : null , candidates : [ ] } )
201255 queryMock . mockImplementation ( async ( sql : string ) =>
202256 typeof sql === 'string' && sql . includes ( 'to_regclass' )
203257 ? { rows : [ { exists : null } ] }
@@ -220,7 +274,7 @@ describe('encrypt drop — EQL version awareness', () => {
220274 beforeEach ( ( ) => {
221275 vi . clearAllMocks ( )
222276 queryMock . mockResolvedValue ( { rows : [ ] } )
223- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V3_INFO )
277+ lifecycleMock . mockResolvedValue ( resolved ( V3_INFO ) )
224278 migrateMocks . countUnencrypted . mockResolvedValue ( 0 )
225279 } )
226280
@@ -246,6 +300,24 @@ describe('encrypt drop — EQL version awareness', () => {
246300 )
247301 } )
248302
303+ it ( 'v3: the generated migration re-verifies coverage at APPLY time, atomically' , async ( ) => {
304+ migrateMocks . progress . mockResolvedValueOnce ( { phase : 'backfilled' } )
305+
306+ await dropCommand ( { table : 'users' , column : 'email' } )
307+
308+ // The CLI-side count goes stale the moment the file is written; the
309+ // migration must lock, re-count, and abort without dropping if any
310+ // plaintext-only row appeared between generation and application.
311+ const sql = writeFileMock . mock . calls [ 0 ] ?. [ 1 ] as string
312+ expect ( sql ) . toContain ( 'LOCK TABLE "users" IN ACCESS EXCLUSIVE MODE' )
313+ expect ( sql ) . toContain ( 'RAISE EXCEPTION' )
314+ expect ( sql ) . toContain ( '"email" IS NOT NULL' )
315+ expect ( sql ) . toContain ( '"email_encrypted" IS NULL' )
316+ // The DROP itself runs inside the same DO block (EXECUTE), so
317+ // check-and-drop stay atomic even under non-transactional runners.
318+ expect ( sql ) . toMatch ( / E X E C U T E ' A L T E R T A B L E " u s e r s " D R O P C O L U M N " e m a i l " ' / )
319+ } )
320+
249321 it ( 'v3: refuses to generate when rows are still plaintext-only' , async ( ) => {
250322 migrateMocks . progress . mockResolvedValueOnce ( { phase : 'backfilled' } )
251323 migrateMocks . countUnencrypted . mockResolvedValueOnce ( 7 )
@@ -266,7 +338,7 @@ describe('encrypt drop — EQL version awareness', () => {
266338 } )
267339
268340 it ( 'v3: gates coverage on the RESOLVED encrypted column name' , async ( ) => {
269- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V3_CUSTOM_INFO )
341+ lifecycleMock . mockResolvedValue ( resolved ( V3_CUSTOM_INFO , 'hint' ) )
270342 migrateMocks . progress . mockResolvedValueOnce ( { phase : 'backfilled' } )
271343
272344 await dropCommand ( { table : 'users' , column : 'email' } )
@@ -281,6 +353,54 @@ describe('encrypt drop — EQL version awareness', () => {
281353 expect ( sql ) . toContain ( 'DROP COLUMN "email"' )
282354 } )
283355
356+ it ( "v3: refuses a by-elimination ('sole') match — uniqueness cannot prove the pairing" , async ( ) => {
357+ // The table's ONE EQL column may encrypt a DIFFERENT field; gating
358+ // coverage on it and dropping `email` could destroy the only copy.
359+ lifecycleMock . mockResolvedValue (
360+ resolved (
361+ { column : 'secret_blob' , domain : 'eql_v3_text_search' , version : 3 } ,
362+ 'sole' ,
363+ ) ,
364+ )
365+ // No progress stub: the sole-match guard fires before the phase gate
366+ // ever consults cs_migrations. (Queuing an unconsumed
367+ // mockResolvedValueOnce here would leak into the next test.)
368+ const exitSpy = spyExit ( )
369+
370+ await dropCommand ( { table : 'users' , column : 'email' } )
371+
372+ expect ( p . log . error ) . toHaveBeenCalledWith (
373+ expect . stringContaining ( 'nothing confirms it encrypts "email"' ) ,
374+ )
375+ expect ( p . log . error ) . toHaveBeenCalledWith (
376+ expect . stringContaining ( '--encrypted-column secret_blob' ) ,
377+ )
378+ expect ( migrateMocks . countUnencrypted ) . not . toHaveBeenCalled ( )
379+ expect ( writeFileMock ) . not . toHaveBeenCalled ( )
380+ expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
381+ exitSpy . mockRestore ( )
382+ } )
383+
384+ it ( 'fails closed when EQL columns exist but none is identifiable' , async ( ) => {
385+ lifecycleMock . mockResolvedValue ( {
386+ info : null ,
387+ candidates : [
388+ { column : 'a_enc' , domain : 'eql_v3_text_eq' , version : 3 } ,
389+ { column : 'b_enc' , domain : 'eql_v3_text_eq' , version : 3 } ,
390+ ] ,
391+ } )
392+ const exitSpy = spyExit ( )
393+
394+ await dropCommand ( { table : 'users' , column : 'email' } )
395+
396+ expect ( p . log . error ) . toHaveBeenCalledWith (
397+ expect . stringContaining ( 'Cannot identify which encrypted column' ) ,
398+ )
399+ expect ( writeFileMock ) . not . toHaveBeenCalled ( )
400+ expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
401+ exitSpy . mockRestore ( )
402+ } )
403+
284404 it ( 'v3: rejects when not yet backfilled' , async ( ) => {
285405 migrateMocks . progress . mockResolvedValueOnce ( { phase : 'backfilling' } )
286406 const exitSpy = spyExit ( )
@@ -296,7 +416,24 @@ describe('encrypt drop — EQL version awareness', () => {
296416 } )
297417
298418 it ( 'v2: unchanged — requires cut-over, no coverage gate, drops <col>_plaintext (regression pin)' , async ( ) => {
299- migrateMocks . resolveEncryptedColumn . mockResolvedValue ( V2_INFO )
419+ lifecycleMock . mockResolvedValue ( resolved ( V2_INFO ) )
420+ migrateMocks . progress . mockResolvedValueOnce ( { phase : 'cut-over' } )
421+
422+ await dropCommand ( { table : 'users' , column : 'email' } )
423+
424+ const sql = writeFileMock . mock . calls [ 0 ] ?. [ 1 ] as string
425+ expect ( sql ) . toContain ( 'DROP COLUMN "email_plaintext"' )
426+ expect ( migrateMocks . countUnencrypted ) . not . toHaveBeenCalled ( )
427+ } )
428+
429+ it ( 'v2 post-cutover: `email` itself carrying the v2 domain is NOT ambiguity — proceeds down the v2 path' , async ( ) => {
430+ // After cutover renamed the ciphertext onto `email`, no counterpart is
431+ // resolvable BY DESIGN. The fail-closed guard must recognize this state
432+ // rather than blocking the one drop the lifecycle actually wants.
433+ lifecycleMock . mockResolvedValue ( {
434+ info : null ,
435+ candidates : [ { column : 'email' , domain : 'eql_v2_encrypted' , version : 2 } ] ,
436+ } )
300437 migrateMocks . progress . mockResolvedValueOnce ( { phase : 'cut-over' } )
301438
302439 await dropCommand ( { table : 'users' , column : 'email' } )
0 commit comments