@@ -127,14 +127,18 @@ async function fixture(prisma: PrismaClient) {
127127describe ( "empty env rollout through production boundaries" , ( ) => {
128128 for ( const emptyValue of [ "" , " \t " ] ) {
129129 postgresTest (
130- `Vercel pull respects off/on/isolation/rollback for ${ JSON . stringify ( emptyValue ) } ` ,
130+ `Vercel pull skips blanks regardless of the direct-write flag for ${ JSON . stringify ( emptyValue ) } ` ,
131131 async ( { prisma } ) => {
132132 const operations : string [ ] = [ ] ;
133+ let rejectGlobalFlagRead = false ;
133134 const observed = prisma . $extends ( {
134135 query : {
135136 $allModels : {
136137 $allOperations : async ( { model, operation, args, query } ) => {
137138 operations . push ( `${ model } .${ operation } ` ) ;
139+ if ( rejectGlobalFlagRead && model === "FeatureFlag" && operation === "findFirst" ) {
140+ throw new Error ( "flag lookup unavailable" ) ;
141+ }
138142 return query ( args ) ;
139143 } ,
140144 } ,
@@ -168,37 +172,51 @@ describe("empty env rollout through production boundaries", () => {
168172 await f . repository . create ( f . project . id , {
169173 override : true ,
170174 environmentIds : [ f . environment . id ] ,
171- variables : [ { key : "PLAIN" , value : "original" } ] ,
175+ variables : [
176+ { key : "PLAIN" , value : "original" } ,
177+ { key : "SENSITIVE" , value : "original-secret" } ,
178+ ] ,
172179 lastUpdatedBy : { type : "user" , userId : f . user . id } ,
173180 } ) ;
181+ // The existing global flag lookup must still fail before provider reads.
182+ rejectGlobalFlagRead = true ;
183+ expect ( ( await pull ( ) ) . isErr ( ) ) . toBe ( true ) ;
184+ expect ( external . calls ) . toEqual ( [ ] ) ;
185+ rejectGlobalFlagRead = false ;
174186 operations . length = 0 ;
175187 const off = await pull ( ) ;
176188 expect ( off . isOk ( ) ) . toBe ( true ) ;
177189 if ( off . isOk ( ) ) expect ( off . value . errors ) . toEqual ( [ ] ) ;
178190 expect ( await f . read ( ) ) . toEqual ( {
179191 PLAIN : "original" ,
192+ SENSITIVE : "original-secret" ,
180193 ENCRYPTED : "encrypted-shared-fallback" ,
181194 COLLISION : "shared-fallback" ,
182195 ORDINARY : "updated" ,
183196 } ) ;
184197 expect ( operations . filter ( ( op ) => op === "FeatureFlag.findFirst" ) ) . toHaveLength ( 1 ) ;
198+ // Set a value distinct from the provider fallback. An enabled blank project
199+ // override must be skipped after merging, not replaced with a shared value.
200+ await f . repository . create ( f . project . id , {
201+ override : true ,
202+ environmentIds : [ f . environment . id ] ,
203+ variables : [ { key : "COLLISION" , value : "keep-collision" } ] ,
204+ } ) ;
185205 await f . setFlag ( true ) ;
186206 external . calls . length = 0 ;
187207 const on = await pull ( ) ;
188208 expect ( on . isOk ( ) ) . toBe ( true ) ;
189209 if ( on . isOk ( ) ) expect ( on . value . errors ) . toEqual ( [ ] ) ;
190210 expect ( await f . read ( ) ) . toEqual ( {
191- PLAIN : emptyValue ,
192- ENCRYPTED : emptyValue ,
193- COLLISION : emptyValue ,
194- SHARED : emptyValue ,
211+ PLAIN : "original" ,
212+ SENSITIVE : "original-secret" ,
213+ ENCRYPTED : "encrypted-shared-fallback" ,
214+ COLLISION : "keep-collision" ,
195215 ORDINARY : "updated" ,
196216 } ) ;
197217 expect ( external . calls . filter ( ( p ) => p === "/v1/env" ) ) . toHaveLength ( 1 ) ;
198218 expect ( external . calls . filter ( ( p ) => p . endsWith ( "/encrypted" ) ) ) . toHaveLength ( 1 ) ;
199- expect ( external . calls . some ( ( p ) => p . endsWith ( "/shared" ) || p . endsWith ( "/sensitive" ) ) ) . toBe (
200- false
201- ) ;
219+ expect ( external . calls . some ( ( p ) => p . endsWith ( "/sensitive" ) ) ) . toBe ( false ) ;
202220 operations . length = 0 ;
203221 const repeat = await pull ( ) ;
204222 expect ( repeat . isOk ( ) && repeat . value . syncedCount ) . toBe ( 0 ) ;
@@ -214,29 +232,28 @@ describe("empty env rollout through production boundaries", () => {
214232 ENCRYPTED : "encrypted-shared-fallback" ,
215233 ORDINARY : "updated" ,
216234 } ) ;
235+ // An explicit false override keeps the original off-path fallback policy
236+ // and bypasses the global flag lookup, even when that lookup would fail.
237+ await f . setFlag ( false ) ;
238+ rejectGlobalFlagRead = true ;
239+ operations . length = 0 ;
240+ expect ( ( await pull ( ) ) . isOk ( ) ) . toBe ( true ) ;
241+ expect ( ( await f . read ( ) ) . COLLISION ) . toBe ( "shared-fallback" ) ;
242+ expect ( operations . filter ( ( op ) => op === "FeatureFlag.findFirst" ) ) . toHaveLength ( 0 ) ;
243+ rejectGlobalFlagRead = false ;
217244 external . deny ( ) ;
218245 const failed = await pull ( ) ;
219246 expect ( failed . isOk ( ) && failed . value . errors . length ) . toBeGreaterThan ( 0 ) ;
220- expect ( ( await f . read ( ) ) . PLAIN ) . toBe ( emptyValue ) ;
247+ expect ( ( await f . read ( ) ) . PLAIN ) . toBe ( "original" ) ;
221248 await f . setFlag ( false ) ;
222249 const stored = await f . read ( ) ;
223- expect ( stored . PLAIN ) . toBe ( emptyValue ) ;
224- const child = spawnSync (
225- process . execPath ,
226- [
227- "-e" ,
228- 'process.stdout.write(JSON.stringify({present:Object.hasOwn(process.env,"PLAIN"),empty:process.env.PLAIN===""}))' ,
229- ] ,
230- { env : { ...process . env , ...stored } , encoding : "utf8" }
231- ) ;
232- expect ( child . status ) . toBe ( 0 ) ;
233- expect ( JSON . parse ( child . stdout ) ) . toEqual ( { present : true , empty : emptyValue === "" } ) ;
250+ expect ( stored . PLAIN ) . toBe ( "original" ) ;
234251 }
235252 ) ;
236253 }
237254
238255 postgresTest (
239- "REST update and billing-shaped import enforce the current organization flag " ,
256+ "direct REST writes allow empty values when enabled while Vercel imports preserve existing values " ,
240257 async ( { prisma } ) => {
241258 const f = await fixture ( prisma ) ;
242259 const params = { projectRef : f . project . externalRef , slug : "prod" , name : "REST_EMPTY" } ;
@@ -252,7 +269,12 @@ describe("empty env rollout through production boundaries", () => {
252269 await f . repository . create ( f . project . id , {
253270 override : true ,
254271 environmentIds : [ f . environment . id ] ,
255- variables : [ { key : "REST_EMPTY" , value : "original" } ] ,
272+ variables : [
273+ { key : "REST_EMPTY" , value : "original" } ,
274+ { key : "BILLING_EMPTY" , value : "original" } ,
275+ { key : "BILLING_WHITESPACE" , value : "original" } ,
276+ { key : "SENSITIVE" , value : "original-secret" } ,
277+ ] ,
256278 } ) ;
257279 const capability = async ( ) => {
258280 const response = await readCapability ( { params, context : { } , request : request ( "GET" ) } ) ;
@@ -265,24 +287,94 @@ describe("empty env rollout through production boundaries", () => {
265287 const off = await update ( ) ;
266288 expect ( off ?. status ) . toBe ( 400 ) ;
267289 expect ( ( await f . read ( ) ) . REST_EMPTY ) . toBe ( "original" ) ;
290+ const protectedKeys = [ "BILLING_EMPTY" , "BILLING_WHITESPACE" , "SENSITIVE" ] ;
291+ const snapshotProtectedValues = async ( ) => {
292+ const rows = await prisma . environmentVariableValue . findMany ( {
293+ where : {
294+ environmentId : f . environment . id ,
295+ variable : { OR : protectedKeys . map ( ( key ) => ( { key } ) ) } ,
296+ } ,
297+ include : { valueReference : true } ,
298+ orderBy : { id : "asc" } ,
299+ } ) ;
300+ const secrets = await prisma . secretStore . findMany ( {
301+ where : { OR : rows . map ( ( row ) => ( { key : row . valueReference ! . key } ) ) } ,
302+ orderBy : { key : "asc" } ,
303+ } ) ;
304+ return { rows, secrets } ;
305+ } ;
306+ const beforeSync = await snapshotProtectedValues ( ) ;
268307 const billingBody = {
269- variables : { BILLING_EMPTY : "" , BILLING_NORMAL : "updated" } ,
308+ variables : {
309+ BILLING_EMPTY : "" ,
310+ BILLING_WHITESPACE : " \t " ,
311+ SENSITIVE : "" ,
312+ NEW_EMPTY : "" ,
313+ BILLING_NORMAL : "updated" ,
314+ } ,
270315 override : true ,
271316 source : { type : "integration" , integration : "vercel" } ,
272317 } ;
273318 const sync = ( ) =>
274319 importVariables ( { params, context : { } , request : request ( "POST" , billingBody ) } ) ;
275320 expect ( ( await sync ( ) ) . status ) . toBe ( 200 ) ;
276- expect ( ( await f . read ( ) ) . BILLING_EMPTY ) . toBeUndefined ( ) ;
321+ expect ( ( await f . read ( ) ) . BILLING_EMPTY ) . toBe ( "original" ) ;
277322 await f . setFlag ( true ) ;
278323 expect ( ( await capability ( ) ) . allowEmptyEnvironmentVariableValues ) . toBe ( true ) ;
279324 expect ( ( await update ( ) ) ?. status ) . toBe ( 200 ) ;
280- expect ( ( await sync ( ) ) . status ) . toBe ( 200 ) ;
325+ // Old billing may still send blanks; retries/concurrent requests must not
326+ // alter stored ciphertext, versions, row IDs, timestamps or provenance.
327+ for ( const result of await Promise . all ( [ sync ( ) , sync ( ) , sync ( ) ] ) ) {
328+ expect ( result . status ) . toBe ( 200 ) ;
329+ }
330+ expect ( await snapshotProtectedValues ( ) ) . toEqual ( beforeSync ) ;
281331 const response = await readVariable ( { params, context : { } , request : request ( "GET" ) } ) ;
282332 expect ( response . status ) . toBe ( 200 ) ;
283333 expect ( await response . json ( ) ) . toMatchObject ( { name : "REST_EMPTY" , value : "" } ) ;
284- expect ( ( await f . read ( ) ) . BILLING_EMPTY ) . toBe ( "" ) ;
334+ expect ( await f . read ( ) ) . toMatchObject ( {
335+ REST_EMPTY : "" ,
336+ BILLING_EMPTY : "original" ,
337+ BILLING_WHITESPACE : "original" ,
338+ SENSITIVE : "original-secret" ,
339+ BILLING_NORMAL : "updated" ,
340+ } ) ;
341+ expect ( ( await f . read ( ) ) . NEW_EMPTY ) . toBeUndefined ( ) ;
342+ const blankOnly = await importVariables ( {
343+ params,
344+ context : { } ,
345+ request : request ( "POST" , {
346+ ...billingBody ,
347+ variables : { BILLING_EMPTY : "" , NEW_EMPTY : "" } ,
348+ } ) ,
349+ } ) ;
350+ expect ( blankOnly . status ) . toBe ( 200 ) ;
351+ expect ( await snapshotProtectedValues ( ) ) . toEqual ( beforeSync ) ;
352+ expect ( ( await f . read ( ) ) . BILLING_EMPTY ) . toBe ( "original" ) ;
353+ expect ( ( await f . read ( ) ) . NEW_EMPTY ) . toBeUndefined ( ) ;
354+ const directImport = await importVariables ( {
355+ params,
356+ context : { } ,
357+ request : request ( "POST" , {
358+ variables : { DIRECT_IMPORT_EMPTY : "" } ,
359+ override : true ,
360+ } ) ,
361+ } ) ;
362+ expect ( directImport . status ) . toBe ( 200 ) ;
363+ expect ( ( await f . read ( ) ) . DIRECT_IMPORT_EMPTY ) . toBe ( "" ) ;
364+ const stored = await f . read ( ) ;
365+ const child = spawnSync (
366+ process . execPath ,
367+ [
368+ "-e" ,
369+ 'process.stdout.write(JSON.stringify({present:Object.hasOwn(process.env,"REST_EMPTY"),empty:process.env.REST_EMPTY===""}))' ,
370+ ] ,
371+ { env : { ...process . env , ...stored } , encoding : "utf8" }
372+ ) ;
373+ expect ( child . status ) . toBe ( 0 ) ;
374+ expect ( JSON . parse ( child . stdout ) ) . toEqual ( { present : true , empty : true } ) ;
285375 await f . setFlag ( false ) ;
376+ expect ( ( await sync ( ) ) . status ) . toBe ( 200 ) ;
377+ expect ( await snapshotProtectedValues ( ) ) . toEqual ( beforeSync ) ;
286378 expect ( ( await update ( ) ) ?. status ) . toBe ( 400 ) ;
287379 const rolledBack = await capability ( ) ;
288380 expect ( rolledBack . allowEmptyEnvironmentVariableValues ) . toBe ( false ) ;
@@ -291,3 +383,36 @@ describe("empty env rollout through production boundaries", () => {
291383 }
292384 ) ;
293385} ) ;
386+
387+ postgresTest (
388+ "Vercel blank imports preserve the existing flag-read failure boundary" ,
389+ async ( { prisma } ) => {
390+ let fail = false ;
391+ const observed = prisma . $extends ( {
392+ query : {
393+ featureFlag : {
394+ findFirst : async ( { args, query } ) => {
395+ if ( fail ) throw new Error ( "flag lookup unavailable" ) ;
396+ return query ( args ) ;
397+ } ,
398+ } ,
399+ } ,
400+ } ) as unknown as PrismaClient ;
401+ const f = await fixture ( observed ) ;
402+ await f . repository . create ( f . project . id , {
403+ override : true ,
404+ environmentIds : [ f . environment . id ] ,
405+ variables : [ { key : "PRESERVE" , value : "original" } ] ,
406+ } ) ;
407+ fail = true ;
408+ await expect (
409+ f . repository . create ( f . project . id , {
410+ override : true ,
411+ environmentIds : [ f . environment . id ] ,
412+ variables : [ { key : "PRESERVE" , value : "" } ] ,
413+ lastUpdatedBy : { type : "integration" , integration : "vercel" } ,
414+ } )
415+ ) . rejects . toThrow ( "flag lookup unavailable" ) ;
416+ expect ( ( await f . read ( ) ) . PRESERVE ) . toBe ( "original" ) ;
417+ }
418+ ) ;
0 commit comments