From d356518cb8771af884d458d7c18d702a98321c20 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 23 Jul 2026 09:57:17 +0100 Subject: [PATCH 1/5] test: add firebase-functions v7 CI leg; mockConfig throws on v7+ functions.config() was removed in firebase-functions v7, so mockConfig() silently succeeding there is a trap: the mock "works" but every subsequent config() read throws. mockConfig() now detects the installed firebase-functions major version and throws with migration guidance on v7+, which makes the >=4.9.0 peer range honest. The unit CI job gains a firebase-functions matrix dimension: the existing pinned leg, plus a v7 leg that compiles the specs against the pinned typings, swaps in firebase-functions@^7, and runs mocha on the compiled output (the specs do not compile against v7 typings). The mockConfig specs are version-aware and assert the throw on v7. Refs #333 --- .github/workflows/test.yaml | 20 ++++++++++- spec/lifecycle.spec.ts | 16 +++++++-- spec/main.spec.ts | 41 +++++++++++++++++------ src/v1.ts | 67 +++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f0722cc5..6af1d05c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,6 +15,9 @@ jobs: node-version: - 22.x - 24.x + firebase-functions: + - pinned + - '7' steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -30,7 +33,22 @@ jobs: - run: npm ci - run: npm run lint - run: npm run format - - run: npm run test + - name: Run tests (pinned firebase-functions) + if: matrix.firebase-functions == 'pinned' + run: npm run test + # Specs do not compile against firebase-functions v7 typings, so + # compile against the pinned version, swap in v7, and run the + # compiled output. This mirrors how users hit version drift: their + # compiled code running against a newer firebase-functions. + - name: Compile specs against pinned firebase-functions + if: matrix.firebase-functions == '7' + run: npx tsc + - name: Swap in firebase-functions@7 + if: matrix.firebase-functions == '7' + run: npm install --no-save firebase-functions@^7 + - name: Run tests (firebase-functions@7) + if: matrix.firebase-functions == '7' + run: npx mocha .tmp/spec/index.spec.js integration: runs-on: ubuntu-latest strategy: diff --git a/spec/lifecycle.spec.ts b/spec/lifecycle.spec.ts index 7d7b2493..3aaa0852 100644 --- a/spec/lifecycle.spec.ts +++ b/spec/lifecycle.spec.ts @@ -24,8 +24,20 @@ import { expect } from 'chai'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { mockConfig } from '../src/main'; +import { _firebaseFunctionsMajorVersion } from '../src/v1'; import { afterEach } from 'mocha'; +// mockConfig() throws on firebase-functions v7+ because functions.config() +// was removed. These tests only care about CLOUD_RUNTIME_CONFIG being +// restored by cleanup(), so set the variable directly on v7+. +function setRuntimeConfig(conf: { [key: string]: { [key: string]: any } }) { + if ((_firebaseFunctionsMajorVersion() ?? 0) >= 7) { + process.env.CLOUD_RUNTIME_CONFIG = JSON.stringify(conf); + } else { + mockConfig(conf); + } +} + describe('lifecycle', () => { describe('#init', () => { let test; @@ -87,7 +99,7 @@ describe('lifecycle', () => { it('deletes all the env variables if they did not previously exist', () => { let test = new FirebaseFunctionsTest(); test.init(); - mockConfig({ foo: { bar: 'faz ' } }); + setRuntimeConfig({ foo: { bar: 'faz ' } }); test.cleanup(); expect(process.env.FIREBASE_CONFIG).to.be.undefined; expect(process.env.GCLOUD_PROJECT).to.be.undefined; @@ -103,7 +115,7 @@ describe('lifecycle', () => { let test = new FirebaseFunctionsTest(); test.init(); - mockConfig({ foo: { bar: 'faz ' } }); + setRuntimeConfig({ foo: { bar: 'faz ' } }); test.cleanup(); expect(process.env.FIREBASE_CONFIG).to.equal('oldFb'); diff --git a/spec/main.spec.ts b/spec/main.spec.ts index 703495c8..150678dc 100644 --- a/spec/main.spec.ts +++ b/spec/main.spec.ts @@ -25,7 +25,11 @@ import * as functions from 'firebase-functions/v1'; import { set } from 'lodash'; import { mockConfig, makeChange, wrap } from '../src/main'; -import { _makeResourceName, _extractParams } from '../src/v1'; +import { + _makeResourceName, + _extractParams, + _firebaseFunctionsMajorVersion, +} from '../src/v1'; import { features } from '../src/features'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { alerts } from 'firebase-functions/v2'; @@ -329,17 +333,32 @@ describe('main', () => { delete process.env.CLOUD_RUNTIME_CONFIG; }); - it('should mock functions.config()', () => { - mockConfig(config); - expect(functions.config()).to.deep.equal(config); - }); + if ((_firebaseFunctionsMajorVersion() ?? 0) >= 7) { + // functions.config() was removed in firebase-functions v7, so + // mockConfig() must fail loudly with migration guidance. + it('should throw explaining that functions.config() was removed', () => { + expect(() => mockConfig(config)).to.throw( + 'mockConfig() is not supported with firebase-functions v7+' + ); + }); + + it('should not set CLOUD_RUNTIME_CONFIG when throwing', () => { + expect(() => mockConfig(config)).to.throw(); + expect(process.env.CLOUD_RUNTIME_CONFIG).to.be.undefined; + }); + } else { + it('should mock functions.config()', () => { + mockConfig(config); + expect(functions.config()).to.deep.equal(config); + }); - it('should purge singleton config object when it is present', () => { - mockConfig(config); - config.foo = { baz: 'qux' }; - mockConfig(config); + it('should purge singleton config object when it is present', () => { + mockConfig(config); + config.foo = { baz: 'qux' }; + mockConfig(config); - expect(functions.config()).to.deep.equal(config); - }); + expect(functions.config()).to.deep.equal(config); + }); + } }); }); diff --git a/src/v1.ts b/src/v1.ts index 6fed427d..02e75221 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -20,6 +20,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import * as fs from 'fs'; +import * as path from 'path'; + import { has, merge, random, get } from 'lodash'; import { @@ -369,8 +372,72 @@ export function makeChange(before: T, after: T): Change { return Change.fromObjects(before, after); } +/** + * Detects the major version of the installed firebase-functions package. + * Exported for internal testing purposes only. + * @internal + */ +export function _firebaseFunctionsMajorVersion(): number | undefined { + try { + // `firebase-functions/package.json` is not exposed by the package's + // exports map, so resolve the main entry point and walk up to the + // package root instead. + let dir = path.dirname(require.resolve('firebase-functions')); + while (dir !== path.dirname(dir)) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if ( + pkg.name === 'firebase-functions' && + typeof pkg.version === 'string' + ) { + return Number(pkg.version.split('.')[0]); + } + } + dir = path.dirname(dir); + } + } catch (e) { + // Fall through to feature detection. + } + return undefined; +} + +/** + * Returns true if the installed firebase-functions no longer supports + * `functions.config()` (removed in v7). + */ +function isConfigRemoved(): boolean { + const major = _firebaseFunctionsMajorVersion(); + if (major !== undefined) { + return major >= 7; + } + // Fallback: feature-detect on the v1 entry point. In v7+, config() + // throws unconditionally; in v4-v6 it parses CLOUD_RUNTIME_CONFIG. + const previous = process.env.CLOUD_RUNTIME_CONFIG; + process.env.CLOUD_RUNTIME_CONFIG = '{}'; + try { + config(); + return false; + } catch (e) { + return true; + } finally { + if (previous === undefined) { + delete process.env.CLOUD_RUNTIME_CONFIG; + } else { + process.env.CLOUD_RUNTIME_CONFIG = previous; + } + } +} + /** Mock values returned by `functions.config()`. */ export function mockConfig(conf: { [key: string]: { [key: string]: any } }) { + if (isConfigRemoved()) { + throw new Error( + 'mockConfig() is not supported with firebase-functions v7+ because ' + + 'functions.config() was removed. Migrate to environment parameters ' + + 'using the params module.' + ); + } if (resetCache) { resetCache(); } From 18e1794522cd4b726a9864d181f14faa909a6d36 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 23 Jul 2026 10:12:31 +0100 Subject: [PATCH 2/5] fix: unify config-removal detection and harden version probe - export _isConfigRemoved() and use it in both spec files instead of branching on _firebaseFunctionsMajorVersion() - add defensive typeof check on config before probing - parse major version with parseInt and reject NaN instead of returning it - clear and restore K_CONFIGURATION around the feature-detect fallback so v4-v6 is not misclassified under GCFv2 - add CHANGELOG entry for the mockConfig() v7+ migration error - run lint/format only on the pinned CI matrix legs --- .github/workflows/test.yaml | 8 ++++++-- CHANGELOG.md | 1 + spec/lifecycle.spec.ts | 4 ++-- spec/main.spec.ts | 8 ++------ src/v1.ts | 23 ++++++++++++++++++++--- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6af1d05c..865495eb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,8 +31,12 @@ jobs: key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} - run: npm ci - - run: npm run lint - - run: npm run format + - name: Lint + if: matrix.firebase-functions == 'pinned' + run: npm run lint + - name: Format + if: matrix.firebase-functions == 'pinned' + run: npm run format - name: Run tests (pinned firebase-functions) if: matrix.firebase-functions == 'pinned' run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cb8f46..58c03855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - chore: drop support for Node 18 and below (minimum supported version is now Node 20) +- fix: mockConfig() now throws a clear migration error when firebase-functions v7+ is installed, since functions.config() was removed upstream (#334) diff --git a/spec/lifecycle.spec.ts b/spec/lifecycle.spec.ts index 3aaa0852..7be06c39 100644 --- a/spec/lifecycle.spec.ts +++ b/spec/lifecycle.spec.ts @@ -24,14 +24,14 @@ import { expect } from 'chai'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { mockConfig } from '../src/main'; -import { _firebaseFunctionsMajorVersion } from '../src/v1'; +import { _isConfigRemoved } from '../src/v1'; import { afterEach } from 'mocha'; // mockConfig() throws on firebase-functions v7+ because functions.config() // was removed. These tests only care about CLOUD_RUNTIME_CONFIG being // restored by cleanup(), so set the variable directly on v7+. function setRuntimeConfig(conf: { [key: string]: { [key: string]: any } }) { - if ((_firebaseFunctionsMajorVersion() ?? 0) >= 7) { + if (_isConfigRemoved()) { process.env.CLOUD_RUNTIME_CONFIG = JSON.stringify(conf); } else { mockConfig(conf); diff --git a/spec/main.spec.ts b/spec/main.spec.ts index 150678dc..808cebad 100644 --- a/spec/main.spec.ts +++ b/spec/main.spec.ts @@ -25,11 +25,7 @@ import * as functions from 'firebase-functions/v1'; import { set } from 'lodash'; import { mockConfig, makeChange, wrap } from '../src/main'; -import { - _makeResourceName, - _extractParams, - _firebaseFunctionsMajorVersion, -} from '../src/v1'; +import { _makeResourceName, _extractParams, _isConfigRemoved } from '../src/v1'; import { features } from '../src/features'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { alerts } from 'firebase-functions/v2'; @@ -333,7 +329,7 @@ describe('main', () => { delete process.env.CLOUD_RUNTIME_CONFIG; }); - if ((_firebaseFunctionsMajorVersion() ?? 0) >= 7) { + if (_isConfigRemoved()) { // functions.config() was removed in firebase-functions v7, so // mockConfig() must fail loudly with migration guidance. it('should throw explaining that functions.config() was removed', () => { diff --git a/src/v1.ts b/src/v1.ts index 02e75221..1dc0ee02 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -391,7 +391,10 @@ export function _firebaseFunctionsMajorVersion(): number | undefined { pkg.name === 'firebase-functions' && typeof pkg.version === 'string' ) { - return Number(pkg.version.split('.')[0]); + const major = parseInt(pkg.version.split('.')[0], 10); + if (!Number.isNaN(major)) { + return major; + } } } dir = path.dirname(dir); @@ -405,16 +408,25 @@ export function _firebaseFunctionsMajorVersion(): number | undefined { /** * Returns true if the installed firebase-functions no longer supports * `functions.config()` (removed in v7). + * Exported for internal testing purposes only. + * @internal */ -function isConfigRemoved(): boolean { +export function _isConfigRemoved(): boolean { + if (typeof config !== 'function') { + return true; + } const major = _firebaseFunctionsMajorVersion(); if (major !== undefined) { return major >= 7; } // Fallback: feature-detect on the v1 entry point. In v7+, config() // throws unconditionally; in v4-v6 it parses CLOUD_RUNTIME_CONFIG. + // K_CONFIGURATION must be cleared for the probe, since v4-v6's config() + // also throws unconditionally when it is set (GCFv2 detection). const previous = process.env.CLOUD_RUNTIME_CONFIG; + const previousKConfiguration = process.env.K_CONFIGURATION; process.env.CLOUD_RUNTIME_CONFIG = '{}'; + delete process.env.K_CONFIGURATION; try { config(); return false; @@ -426,12 +438,17 @@ function isConfigRemoved(): boolean { } else { process.env.CLOUD_RUNTIME_CONFIG = previous; } + if (previousKConfiguration === undefined) { + delete process.env.K_CONFIGURATION; + } else { + process.env.K_CONFIGURATION = previousKConfiguration; + } } } /** Mock values returned by `functions.config()`. */ export function mockConfig(conf: { [key: string]: { [key: string]: any } }) { - if (isConfigRemoved()) { + if (_isConfigRemoved()) { throw new Error( 'mockConfig() is not supported with firebase-functions v7+ because ' + 'functions.config() was removed. Migrate to environment parameters ' + From e193ed7e5a774f7bae38e79439a21eea3143186c Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 23 Jul 2026 10:32:18 +0100 Subject: [PATCH 3/5] ci: pin action refs to commit SHAs --- .github/workflows/test.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 865495eb..63d56f0d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,13 +19,13 @@ jobs: - pinned - '7' steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node-version }} - name: Cache npm - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.npm key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} @@ -61,13 +61,13 @@ jobs: - 22.x - 24.x steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node-version }} - name: Cache npm - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.npm key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} From 2b283ba2c8298ca029e1df984e4893fb963761b0 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 23 Jul 2026 10:35:22 +0100 Subject: [PATCH 4/5] ci: restrict workflow token to contents: read --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 63d56f0d..80b7feb2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,9 @@ on: - pull_request - push +permissions: + contents: read + env: CI: true From 3f034409a03de0325419abfa2c92d9a8b2bb3e90 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 23 Jul 2026 11:00:48 +0100 Subject: [PATCH 5/5] perf: memoize config-removal detection --- src/v1.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/v1.ts b/src/v1.ts index 1dc0ee02..2580dc3e 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -405,13 +405,23 @@ export function _firebaseFunctionsMajorVersion(): number | undefined { return undefined; } +let isConfigRemovedCache: boolean | undefined; + /** * Returns true if the installed firebase-functions no longer supports - * `functions.config()` (removed in v7). + * `functions.config()` (removed in v7). The result is cached, since the + * installed version cannot change within a process. * Exported for internal testing purposes only. * @internal */ export function _isConfigRemoved(): boolean { + if (isConfigRemovedCache === undefined) { + isConfigRemovedCache = detectConfigRemoved(); + } + return isConfigRemovedCache; +} + +function detectConfigRemoved(): boolean { if (typeof config !== 'function') { return true; }