1- import { describe , expect , it } from 'vitest'
2- import { buildEqlV3MigrationSql } from '../migration.js'
1+ import {
2+ existsSync ,
3+ mkdirSync ,
4+ mkdtempSync ,
5+ readFileSync ,
6+ rmSync ,
7+ writeFileSync ,
8+ } from 'node:fs'
9+ import { tmpdir } from 'node:os'
10+ import { join } from 'node:path'
11+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
12+ import { CliExit } from '../../../cli/exit.js'
13+ import { messages } from '../../../messages.js'
14+ import { buildEqlV3MigrationSql , eqlMigrationCommand } from '../migration.js'
15+
16+ // clack is chrome — silence it and spy on the error/note channels the command
17+ // reports through.
18+ const clack = vi . hoisted ( ( ) => ( {
19+ spinnerInstance : { start : vi . fn ( ) , stop : vi . fn ( ) } ,
20+ log : { info : vi . fn ( ) , warn : vi . fn ( ) , error : vi . fn ( ) , success : vi . fn ( ) } ,
21+ intro : vi . fn ( ) ,
22+ note : vi . fn ( ) ,
23+ outro : vi . fn ( ) ,
24+ } ) )
25+ vi . mock ( '@clack/prompts' , ( ) => ( {
26+ spinner : vi . fn ( ( ) => clack . spinnerInstance ) ,
27+ log : clack . log ,
28+ intro : clack . intro ,
29+ note : clack . note ,
30+ outro : clack . outro ,
31+ } ) )
32+
33+ // Stub the drizzle-kit scaffold — only the child process is faked.
34+ const spawnMock = vi . hoisted ( ( ) => vi . fn ( ) )
35+ vi . mock ( 'node:child_process' , ( ) => ( { spawnSync : spawnMock } ) )
36+
37+ // `node:fs` stays REAL by default (so `loadBundledEqlSql` reads the bundled SQL
38+ // and the tmpdir writes/reads work) — only `writeFileSync` is a spy that
39+ // delegates to the real impl, so the cleanup test can make just the SQL write
40+ // throw without touching everything else. `beforeEach` restores the delegating
41+ // default after `clearAllMocks`.
42+ const fsWrite = vi . hoisted ( ( ) => ( {
43+ real : undefined as unknown as typeof import ( 'node:fs' ) . writeFileSync ,
44+ spy : vi . fn ( ) ,
45+ } ) )
46+ vi . mock ( 'node:fs' , async ( importOriginal ) => {
47+ const actual = await importOriginal < typeof import ( 'node:fs' ) > ( )
48+ fsWrite . real = actual . writeFileSync
49+ return { ...actual , default : actual , writeFileSync : fsWrite . spy }
50+ } )
51+
52+ // `printNextSteps` lives in the install module, which drags in `pg`. Stub it;
53+ // the two helpers we reuse (`findGeneratedMigration`, `cleanupMigrationFile`)
54+ // stay real and act on the tmpdir.
55+ vi . mock ( '../../db/install.js' , async ( importOriginal ) => {
56+ const actual = await importOriginal < typeof import ( '../../db/install.js' ) > ( )
57+ return { ...actual , printNextSteps : vi . fn ( ) }
58+ } )
59+
60+ beforeEach ( ( ) => {
61+ fsWrite . spy . mockImplementation ( fsWrite . real )
62+ } )
63+ afterEach ( ( ) => {
64+ vi . clearAllMocks ( )
65+ } )
366
467/**
5- * `buildEqlV3MigrationSql` is the pure core of `stash eql migration --drizzle`:
6- * it assembles the migration contents from the CLI's bundled v3 install SQL,
7- * the optional Supabase grants, and the `cs_migrations` tracking schema. The
8- * file-writing orchestration around it (drizzle-kit scaffold + inject) is thin
9- * and I/O-bound, so the assembly is where the contract lives.
68+ * `buildEqlV3MigrationSql` is the pure core: it assembles the migration from the
69+ * CLI's bundled v3 install SQL, the optional Supabase grants, and the
70+ * `cs_migrations` tracking schema.
1071 */
1172describe ( 'buildEqlV3MigrationSql' , ( ) => {
1273 it ( 'emits the EQL v3 install bundle and the cs_migrations tracking schema' , ( ) => {
1374 const sql = buildEqlV3MigrationSql ( { supabase : false } )
14- // v3 bundle, not v2 — the whole point of the command.
1575 expect ( sql ) . toContain ( 'EQL v3 schema creation' )
1676 expect ( sql ) . toContain ( 'eql_v3' )
17- // Bundled tracking schema so one migration run is enough for `stash encrypt`.
1877 expect ( sql ) . toContain ( 'cs_migrations' )
1978 } )
2079
@@ -35,12 +94,145 @@ describe('buildEqlV3MigrationSql', () => {
3594 )
3695 } )
3796
38- it ( 'is a superset of the non-supabase content when --supabase is set' , ( ) => {
39- const base = buildEqlV3MigrationSql ( { supabase : false } )
40- const supa = buildEqlV3MigrationSql ( { supabase : true } )
41- // The grants are additive: everything in the base still appears.
42- expect ( supa . length ) . toBeGreaterThan ( base . length )
43- expect ( supa ) . toContain ( 'EQL v3 schema creation' )
44- expect ( supa ) . toContain ( 'cs_migrations' )
97+ it ( 'orders the bundle: schema creation → grants → tracking schema' , ( ) => {
98+ // Order is the contract: `GRANT ... ON SCHEMA eql_v3` against a not-yet-
99+ // created schema is a hard error at migrate time, so `toContain` isn't
100+ // enough — the offsets must be monotonic.
101+ const sql = buildEqlV3MigrationSql ( { supabase : true } )
102+ const schemaAt = sql . indexOf ( 'EQL v3 schema creation' )
103+ const grantAt = sql . indexOf ( 'GRANT USAGE ON SCHEMA eql_v3 TO' )
104+ const trackingAt = sql . indexOf (
105+ '-- CipherStash encryption-migration tracking schema.' ,
106+ )
107+ expect ( schemaAt ) . toBeGreaterThanOrEqual ( 0 )
108+ expect ( grantAt ) . toBeGreaterThan ( schemaAt )
109+ expect ( trackingAt ) . toBeGreaterThan ( grantAt )
110+ } )
111+ } )
112+
113+ describe ( 'eqlMigrationCommand — target selection' , ( ) => {
114+ it . each ( [
115+ [ 'no target' , { } , ( ) => messages . eql . migrationNeedsTarget ] ,
116+ [
117+ 'both targets' ,
118+ { drizzle : true , prisma : true } ,
119+ ( ) => messages . eql . migrationOneTarget ,
120+ ] ,
121+ [
122+ '--prisma' ,
123+ { prisma : true } ,
124+ ( ) => messages . eql . migrationPrismaUnavailable ,
125+ ] ,
126+ // `--supabase` is a modifier, not a target.
127+ [
128+ '--supabase alone' ,
129+ { supabase : true } ,
130+ ( ) => messages . eql . migrationNeedsTarget ,
131+ ] ,
132+ ] ) ( 'exits 1 with an actionable message for %s' , async ( _label , opts , msg ) => {
133+ await expect ( eqlMigrationCommand ( opts ) ) . rejects . toBeInstanceOf ( CliExit )
134+ expect ( clack . log . error ) . toHaveBeenCalledWith ( msg ( ) )
135+ expect ( spawnMock ) . not . toHaveBeenCalled ( )
136+ } )
137+ } )
138+
139+ describe ( 'eqlMigrationCommand — Drizzle' , ( ) => {
140+ let tmp : string
141+ beforeEach ( ( ) => {
142+ tmp = mkdtempSync ( join ( tmpdir ( ) , 'stash-eql-migration-' ) )
143+ } )
144+ afterEach ( ( ) => {
145+ rmSync ( tmp , { recursive : true , force : true } )
146+ } )
147+
148+ it ( 'rejects a migration name with unsafe characters before spawning' , async ( ) => {
149+ await expect (
150+ eqlMigrationCommand ( { drizzle : true , name : 'a b; rm -rf /' } ) ,
151+ ) . rejects . toBeInstanceOf ( CliExit )
152+ expect ( clack . log . error ) . toHaveBeenCalledWith ( messages . eql . migrationBadName )
153+ expect ( spawnMock ) . not . toHaveBeenCalled ( )
154+ } )
155+
156+ it ( 'dry run neither spawns drizzle-kit nor creates the out directory' , async ( ) => {
157+ const out = join ( tmp , 'drizzle' )
158+ await eqlMigrationCommand ( { drizzle : true , dryRun : true , out } )
159+ expect ( spawnMock ) . not . toHaveBeenCalled ( )
160+ expect ( existsSync ( out ) ) . toBe ( false )
161+ expect ( clack . note ) . toHaveBeenCalledWith (
162+ expect . stringContaining (
163+ 'drizzle-kit generate --custom --name=install-eql' ,
164+ ) ,
165+ 'Dry Run' ,
166+ )
167+ } )
168+
169+ it ( 'dry run says the grants would be included under --supabase' , async ( ) => {
170+ await eqlMigrationCommand ( {
171+ drizzle : true ,
172+ dryRun : true ,
173+ supabase : true ,
174+ out : join ( tmp , 'd' ) ,
175+ } )
176+ expect ( clack . note ) . toHaveBeenCalledWith (
177+ expect . stringContaining ( '(with Supabase grants)' ) ,
178+ 'Dry Run' ,
179+ )
180+ } )
181+
182+ it ( 'threads --name/--out into drizzle-kit (as argv, no shell) and writes the SQL' , async ( ) => {
183+ const out = join ( tmp , 'db' , 'migrations' )
184+ mkdirSync ( out , { recursive : true } )
185+ // Stand in for drizzle-kit scaffolding an empty custom migration.
186+ spawnMock . mockImplementation ( ( ) => {
187+ writeFileSync ( join ( out , '0000_add-eql.sql' ) , '' )
188+ return { status : 0 , stdout : '' , stderr : '' }
189+ } )
190+
191+ await eqlMigrationCommand ( { drizzle : true , name : 'add-eql' , out } )
192+
193+ // argv array, never a shell string — the name/out are discrete tokens.
194+ const [ , argv ] = spawnMock . mock . calls [ 0 ]
195+ expect ( argv ) . toContain ( 'drizzle-kit' )
196+ expect ( argv ) . toContain ( '--name=add-eql' )
197+ expect ( argv ) . toContain ( `--out=${ out } ` )
198+
199+ const written = readFileSync ( join ( out , '0000_add-eql.sql' ) , 'utf-8' )
200+ expect ( written ) . toContain ( 'EQL v3 schema creation' )
201+ expect ( written ) . toContain ( 'cs_migrations' )
202+ } )
203+
204+ it ( 'aborts (exit 1) when drizzle-kit exits non-zero' , async ( ) => {
205+ spawnMock . mockReturnValue ( { status : 1 , stdout : '' , stderr : 'boom' } )
206+ await expect (
207+ eqlMigrationCommand ( { drizzle : true , out : join ( tmp , 'drizzle' ) } ) ,
208+ ) . rejects . toBeInstanceOf ( CliExit )
209+ expect ( clack . log . error ) . toHaveBeenCalledWith ( 'boom' )
210+ } )
211+
212+ it ( 'removes the scaffolded migration when writing the SQL fails' , async ( ) => {
213+ const out = join ( tmp , 'drizzle' )
214+ mkdirSync ( out , { recursive : true } )
215+ const scaffolded = join ( out , '0000_install-eql.sql' )
216+ spawnMock . mockImplementation ( ( ) => {
217+ // drizzle-kit's empty custom migration (delegates to real write).
218+ fsWrite . real ( scaffolded , '' )
219+ return { status : 0 , stdout : '' , stderr : '' }
220+ } )
221+ // Throw on the SQL write specifically (the big bundle), letting the empty
222+ // scaffold write through — robust to call order.
223+ fsWrite . spy . mockImplementation ( ( ( path : string , data : unknown , ...rest ) => {
224+ if ( typeof data === 'string' && data . includes ( 'EQL v3 schema creation' ) ) {
225+ throw new Error ( 'EACCES: permission denied' )
226+ }
227+ return fsWrite . real ( path , data as string , ...( rest as [ ] ) )
228+ } ) as typeof import ( 'node:fs' ) . writeFileSync )
229+
230+ await expect (
231+ eqlMigrationCommand ( { drizzle : true , out } ) ,
232+ ) . rejects . toBeInstanceOf ( CliExit )
233+
234+ // The empty scaffold must not survive — drizzle-kit would happily run it.
235+ expect ( existsSync ( scaffolded ) ) . toBe ( false )
236+ expect ( clack . log . error ) . toHaveBeenCalledWith ( 'EACCES: permission denied' )
45237 } )
46238} )
0 commit comments