@@ -9,6 +9,7 @@ import * as cli from "./cli";
99import * as proto from "../../src/gcp/proto" ;
1010import * as tasks from "../../src/gcp/cloudtasks" ;
1111import * as scheduler from "../../src/gcp/cloudscheduler" ;
12+ import * as run from "../../src/gcp/run" ;
1213import { Endpoint } from "../../src/deploy/functions/backend" ;
1314import { requireAuth } from "../../src/requireAuth" ;
1415
@@ -352,6 +353,77 @@ describe("firebase deploy", function (this) {
352353 }
353354 } ) ;
354355
356+ it ( "preserves externally-added Cloud Run invoker members when preserveExternalChanges is true" , async ( ) => {
357+ // Regression test for firebase/firebase-tools#6549.
358+ // Covers the scheduled-v2 path which is what the original issue reports.
359+ const TEST_INVOKER = `user:test-preserve-${ RUN_ID } @example.com` ;
360+
361+ // 1. Deploy with the flag set.
362+ const optsOn : Opts = {
363+ v1Opts : { preserveExternalChanges : true } ,
364+ v2Opts : { preserveExternalChanges : true } ,
365+ v1TqOpts : { } ,
366+ v2TqOpts : { } ,
367+ v1IdpOpts : { } ,
368+ v2IdpOpts : { } ,
369+ v1ScheduleOpts : { } ,
370+ v2ScheduleOpts : { schedule : "every 30 minutes" } ,
371+ } ;
372+ const firstDeploy = await setOptsAndDeploy ( optsOn ) ;
373+ expect ( firstDeploy . stdout , "first deploy" ) . to . match ( / D e p l o y c o m p l e t e ! / ) ;
374+
375+ const endpoints = await listFns ( RUN_ID ) ;
376+ const scheduled = Object . values ( endpoints ) . find (
377+ ( e ) => e . platform === "gcfv2" && "scheduleTrigger" in e ,
378+ ) ;
379+ expect ( scheduled , "v2scheduled endpoint" ) . to . not . be . undefined ;
380+
381+ // 2. Add an external invoker member directly to the Cloud Run service.
382+ const runServiceName = `projects/${ FIREBASE_PROJECT } /locations/${ scheduled ! . region } /services/${ scheduled ! . runServiceId } ` ;
383+ const beforePolicy = await run . getIamPolicy ( runServiceName ) ;
384+ const preservedBinding = beforePolicy . bindings ?. find (
385+ ( b ) => b . role === "roles/run.invoker" && ! b . condition ,
386+ ) ;
387+ const preservedMembers = [ ...( preservedBinding ?. members || [ ] ) , TEST_INVOKER ] ;
388+ await run . setIamPolicy ( runServiceName , {
389+ bindings : [
390+ ...( beforePolicy . bindings || [ ] ) . filter ( ( b ) => b . role !== "roles/run.invoker" || b . condition ) ,
391+ { role : "roles/run.invoker" , members : preservedMembers } ,
392+ ] ,
393+ etag : beforePolicy . etag || "" ,
394+ version : 3 ,
395+ } ) ;
396+
397+ // 3. Redeploy with the flag still set — the external invoker must survive.
398+ const secondDeploy = await setOptsAndDeploy ( optsOn ) ;
399+ expect ( secondDeploy . stdout , "second deploy" ) . to . match ( / D e p l o y c o m p l e t e ! | N o c h a n g e s d e t e c t e d / ) ;
400+
401+ const afterOnPolicy = await run . getIamPolicy ( runServiceName ) ;
402+ const afterOnBinding = afterOnPolicy . bindings ?. find (
403+ ( b ) => b . role === "roles/run.invoker" && ! b . condition ,
404+ ) ;
405+ expect ( afterOnBinding ?. members , "invoker members after preserve-on redeploy" ) . to . include (
406+ TEST_INVOKER ,
407+ ) ;
408+
409+ // 4. Flip the flag off and redeploy — the external invoker should now be removed.
410+ const optsOff : Opts = {
411+ ...optsOn ,
412+ v1Opts : { preserveExternalChanges : false } ,
413+ v2Opts : { preserveExternalChanges : false } ,
414+ } ;
415+ const thirdDeploy = await setOptsAndDeploy ( optsOff ) ;
416+ expect ( thirdDeploy . stdout , "third deploy" ) . to . match ( / D e p l o y c o m p l e t e ! | N o c h a n g e s d e t e c t e d / ) ;
417+
418+ const afterOffPolicy = await run . getIamPolicy ( runServiceName ) ;
419+ const afterOffBinding = afterOffPolicy . bindings ?. find (
420+ ( b ) => b . role === "roles/run.invoker" && ! b . condition ,
421+ ) ;
422+ expect ( afterOffBinding ?. members , "invoker members after preserve-off redeploy" ) . to . not . include (
423+ TEST_INVOKER ,
424+ ) ;
425+ } ) ;
426+
355427 // BUGBUG: Setting options to null SHOULD restore their values to default, but this isn't correctly implemented in
356428 // the CLI.
357429 it . skip ( "restores default values when unspecified and preserveExternalChanges is not set" , async ( ) => {
0 commit comments