diff --git a/spec/params/params.spec.ts b/spec/params/params.spec.ts index 19d18c83a..36dd96f5b 100644 --- a/spec/params/params.spec.ts +++ b/spec/params/params.spec.ts @@ -258,6 +258,34 @@ describe("Params value extraction", () => { expect(trueExpr.thenElse(twentytwo, 0).value()).to.equal(22); expect(falseExpr.thenElse(1, twentytwo).value()).to.equal(22); }); + + it("can select between RegExp/RegExp[] literals via a ternary expression", () => { + const localPattern = /^http:\/\/localhost:8080$/; + const prodPattern = /^https:\/\/example\.com$/; + const trueExpr = params.defineString("A_STRING").equals(params.defineString("SAME_STRING")); + const falseExpr = params.defineInt("AN_INT").equals(params.defineInt("DIFF_INT")); + + expect(trueExpr.thenElse(localPattern, prodPattern).value()).to.equal(localPattern); + expect(falseExpr.thenElse(localPattern, prodPattern).value()).to.equal(prodPattern); + + const localPatterns = [localPattern]; + const prodPatterns = [prodPattern]; + expect(trueExpr.thenElse(localPatterns, prodPatterns).value()).to.equal(localPatterns); + expect(falseExpr.thenElse(localPatterns, prodPatterns).value()).to.equal(prodPatterns); + + // Nested thenElse, mirroring a boolean-param-selected CORS origin config. + // The outer test is false so that the nested expression is the one resolved. + const otherPatterns = [/^https:\/\/other\.example\.com$/]; + const stagingExpr = params.defineBoolean("TRUE"); + expect( + falseExpr.thenElse(localPatterns, stagingExpr.thenElse(prodPatterns, otherPatterns)).value() + ).to.equal(prodPatterns); + expect( + falseExpr + .thenElse(localPatterns, stagingExpr.equals(false).thenElse(prodPatterns, otherPatterns)) + .value() + ).to.equal(otherPatterns); + }); }); describe("defineJsonSecret", () => { @@ -457,6 +485,37 @@ describe("Params as CEL", () => { cmpExpr.thenElse(params.defineString("FOO"), params.defineString("BAR")).toCEL() ).to.equal("{{ params.A != params.B ? params.FOO : params.BAR }}"); }); + + it("represents RegExp array branches as their string form, not '{}'", () => { + const booleanExpr = params.defineBoolean("BOOL"); + const localPattern = /^http:\/\/localhost$/; + const prodPattern = /^https:\/\/example\.com$/; + const cel = booleanExpr.thenElse([localPattern], [prodPattern]).toCEL(); + + // Regression check: JSON.stringify(regexArray) alone would render each RegExp + // as "{}", silently dropping the pattern. + expect(cel).to.not.include("{}"); + expect(cel).to.equal( + `{{ params.BOOL ? ${JSON.stringify([localPattern.toString()])} : ${JSON.stringify([ + prodPattern.toString(), + ])} }}` + ); + }); + + it("represents a bare RegExp branch as a quoted string form, not unquoted source", () => { + const booleanExpr = params.defineBoolean("BOOL"); + const localPattern = /^http:\/\/localhost$/; + const prodPattern = /^https:\/\/example\.com$/; + const cel = booleanExpr.thenElse(localPattern, prodPattern).toCEL(); + + // Regression check: arg.toString() alone would render the RegExp as an + // unquoted /pattern/, which is not a valid CEL string literal. + expect(cel).to.equal( + `{{ params.BOOL ? ${JSON.stringify(localPattern.toString())} : ${JSON.stringify( + prodPattern.toString() + )} }}` + ); + }); }); describe("expr template tag", () => { diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 85930c36c..f4a91dcac 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -307,6 +307,89 @@ describe("onRequest", () => { } }); + it("should allow a RegExp[] chosen dynamically via a ternary expression", async () => { + const isStaging = defineBoolean("IS_STAGING"); + const localPattern = /^http:\/\/localhost:8080$/; + const stagingPattern = /^https:\/\/staging\.example\.com$/; + + try { + process.env.IS_STAGING = "true"; + const func = https.onRequest( + { + cors: isStaging.equals(true).thenElse([stagingPattern], [localPattern]), + }, + (req, res) => { + res.send("42"); + } + ); + const req = request({ + headers: { + referrer: "https://staging.example.com", + "content-type": "application/json", + origin: "https://staging.example.com", + }, + method: "OPTIONS", + }); + + const response = await runHandler(func, req); + + expect(response.status).to.equal(204); + expect(response.headers).to.deep.equal({ + "Access-Control-Allow-Origin": "https://staging.example.com", + "Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "Content-Length": "0", + Vary: "Origin, Access-Control-Request-Headers", + }); + } finally { + delete process.env.IS_STAGING; + clearParams(); + } + }); + + it("should resolve the other RegExp[] branch when the ternary expression is false", async () => { + const isStaging = defineBoolean("IS_STAGING"); + const localPattern = /^http:\/\/localhost:8080$/; + const stagingPattern = /^https:\/\/staging\.example\.com$/; + + try { + process.env.IS_STAGING = "false"; + const func = https.onRequest( + { + cors: isStaging.equals(true).thenElse([stagingPattern], [localPattern]), + }, + (req, res) => { + res.send("42"); + } + ); + const preflight = (origin: string) => + request({ + headers: { + referrer: origin, + "content-type": "application/json", + origin, + }, + method: "OPTIONS", + }); + + const allowed = await runHandler(func, preflight("http://localhost:8080")); + + expect(allowed.status).to.equal(204); + expect(allowed.headers).to.deep.equal({ + "Access-Control-Allow-Origin": "http://localhost:8080", + "Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "Content-Length": "0", + Vary: "Origin, Access-Control-Request-Headers", + }); + + const denied = await runHandler(func, preflight("https://staging.example.com")); + + expect(denied.headers).to.not.have.property("Access-Control-Allow-Origin"); + } finally { + delete process.env.IS_STAGING; + clearParams(); + } + }); + it("should add CORS headers if debug feature is enabled", async () => { sinon.stub(debug, "isDebugFeatureEnabled").withArgs("enableCors").returns(true); diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index c636067a7..04f871630 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -715,6 +715,8 @@ export type CorsOption = | string | Expression | Expression + | Expression + | Expression> | boolean | RegExp | Array; diff --git a/src/params/index.ts b/src/params/index.ts index e824f61fa..e130e9251 100644 --- a/src/params/index.ts +++ b/src/params/index.ts @@ -47,6 +47,7 @@ export type { SelectInput, SelectOptions, MultiSelectInput, + ExpressionValue, Param, SecretParam, JsonSecretParam, diff --git a/src/params/types.ts b/src/params/types.ts index c1e834b80..11e0a5678 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -24,12 +24,26 @@ import * as logger from "../logger"; const EXPRESSION_TAG = Symbol.for("firebase-functions:Expression:Tag"); +/** + * The types an `Expression` can resolve to. `string`, `number`, `boolean` and + * `string[]` cover the values a param itself can hold; `RegExp` and + * `Array` are additionally allowed so that expressions can + * select between literals for options that accept them, such as `cors`. + */ +export type ExpressionValue = + | string + | number + | boolean + | string[] + | RegExp + | Array; + /* * A CEL expression which can be evaluated during function deployment, and * resolved to a value of the generic type parameter: i.e, you can pass * an Expression as the value of an option that normally accepts numbers. */ -export abstract class Expression { +export abstract class Expression { /** * Handle the "Dual-Package Hazard" . * @@ -144,13 +158,11 @@ export class TransformedStringExpression extends Expression { } } -export function valueOf(arg: T | Expression): T { +export function valueOf(arg: T | Expression): T { return arg instanceof Expression ? arg.runtimeValue() : arg; } -export function celOf( - arg: T | Expression -): T | string { +export function celOf(arg: T | Expression): T | string { return arg instanceof Expression ? arg.toCEL() : arg; } @@ -171,13 +183,17 @@ export function transform( * - Arrays are represented as []-delimited, parsable JSON * - Numbers and booleans are not quoted explicitly */ -function refOf(arg: T | Expression): string { +function refOf(arg: T | Expression): string { if (arg instanceof Expression) { return arg.toString(); } else if (typeof arg === "string") { return `"${arg}"`; + } else if (arg instanceof RegExp) { + return JSON.stringify(arg.toString()); } else if (Array.isArray(arg)) { - return JSON.stringify(arg); + // RegExp has no useful JSON representation (JSON.stringify(/foo/) === "{}"), + // so fall back to its string form instead of silently dropping the pattern. + return JSON.stringify(arg.map((item) => (item instanceof RegExp ? item.toString() : item))); } else { return arg.toString(); } @@ -186,9 +202,7 @@ function refOf(arg: T | Expressi /** * A CEL expression corresponding to a ternary operator, e.g {{ cond ? ifTrue : ifFalse }} */ -export class TernaryExpression< - T extends string | number | boolean | string[] -> extends Expression { +export class TernaryExpression extends Expression { constructor( private readonly test: Expression, private readonly ifTrue: T | Expression, @@ -263,7 +277,7 @@ export class CompareExpression< } /** Returns a `TernaryExpression` which can resolve to one of two values, based on the resolution of this comparison. */ - thenElse( + thenElse( ifTrue: retT | Expression, ifFalse: retT | Expression ) { @@ -719,14 +733,11 @@ export class BooleanParam extends Param { } /** @deprecated */ - then(ifTrue: T | Expression, ifFalse: T | Expression) { + then(ifTrue: T | Expression, ifFalse: T | Expression) { return this.thenElse(ifTrue, ifFalse); } - thenElse( - ifTrue: T | Expression, - ifFalse: T | Expression - ) { + thenElse(ifTrue: T | Expression, ifFalse: T | Expression) { return new TernaryExpression(this, ifTrue, ifFalse); } } diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 8aa9438ec..43e6dec53 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -76,13 +76,7 @@ export interface HttpsOptions extends Omit - | Expression - | boolean - | RegExp - | Array; + cors?: CorsOption; /** * Amount of memory to allocate to a function.