From f7d60850ee78103492f8f3b3dd09e52ec8e42f63 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 26 Aug 2026 11:35:34 +0100 Subject: [PATCH] fix(bigquery-firestore-export): restore install-time param validation COLLECTION_PATH regains the upstream collection path regex, and BIGQUERY_DATASET_LOCATION becomes a select over the upstream list of regions and multi-regions. Both were free-form strings after the port, so a bad value was accepted at deploy and only failed at runtime. --- kits/bigquery-firestore-export/src/config.ts | 56 +++++++++- .../tests/config.test.ts | 105 +++++++++++++++++- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/kits/bigquery-firestore-export/src/config.ts b/kits/bigquery-firestore-export/src/config.ts index 62abf7857..493e324cc 100644 --- a/kits/bigquery-firestore-export/src/config.ts +++ b/kits/bigquery-firestore-export/src/config.ts @@ -27,12 +27,59 @@ import type { } from "./export-config"; const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "silent"] as const; + +/** + * Mirrors the BigQuery dataset locations offered by the upstream extension. The + * query job must run in the same location as the dataset it reads, so a value + * outside this list only fails once the scheduled query runs. + */ +const BIGQUERY_DATASET_LOCATION_OPTIONS: Record = { + "Columbus, Ohio (us-east5)": "us-east5", + "Iowa (us-central1)": "us-central1", + "Las Vegas (us-west4)": "us-west4", + "Los Angeles (us-west2)": "us-west2", + "Montréal (northamerica-northeast1)": "northamerica-northeast1", + "Northern Virginia (us-east4)": "us-east4", + "Oregon (us-west1)": "us-west1", + "Salt Lake City (us-west3)": "us-west3", + "São Paulo (southamerica-east1)": "southamerica-east1", + "Santiago (southamerica-west1)": "southamerica-west1", + "South Carolina (us-east1)": "us-east1", + "Toronto (northamerica-northeast2)": "northamerica-northeast2", + "Delhi (asia-south2)": "asia-south2", + "Hong Kong (asia-east2)": "asia-east2", + "Jakarta (asia-southeast2)": "asia-southeast2", + "Melbourne (australia-southeast2)": "australia-southeast2", + "Mumbai (asia-south1)": "asia-south1", + "Osaka (asia-northeast2)": "asia-northeast2", + "Seoul (asia-northeast3)": "asia-northeast3", + "Singapore (asia-southeast1)": "asia-southeast1", + "Sydney (australia-southeast1)": "australia-southeast1", + "Taiwan (asia-east1)": "asia-east1", + "Tokyo (asia-northeast1)": "asia-northeast1", + "Belgium (europe-west1)": "europe-west1", + "Finland (europe-north1)": "europe-north1", + "Frankfurt (europe-west3)": "europe-west3", + "London (europe-west2)": "europe-west2", + "Madrid (europe-southwest1)": "europe-southwest1", + "Milan (europe-west8)": "europe-west8", + "Netherlands (europe-west4)": "europe-west4", + "Paris (europe-west9)": "europe-west9", + "Warsaw (europe-central2)": "europe-central2", + "Zürich (europe-west6)": "europe-west6", + "US Multi-Region (US)": "US", + "EU Multi-Region (EU)": "EU", +}; + +/** A Firestore collection path has an odd number of slash-separated segments. */ +const COLLECTION_PATH_REGEX = "^[^/]+(/[^/]+/[^/]+)*$"; const instanceId = defineString("INSTANCE_ID"); -const params = { +export const params = { instanceId, bigqueryDatasetLocation: defineString("BIGQUERY_DATASET_LOCATION", { default: "US", + input: select(BIGQUERY_DATASET_LOCATION_OPTIONS), }), transferConfigName: defineString("TRANSFER_CONFIG_NAME", { default: "" }), datasetId: defineString("DATASET_ID"), @@ -43,6 +90,13 @@ const params = { schedule: defineString("SCHEDULE"), firestoreCollection: defineString("COLLECTION_PATH", { default: "transferConfigs", + input: { + text: { + example: "transferConfigs", + validationRegex: COLLECTION_PATH_REGEX, + validationErrorMessage: "Must be a valid Cloud Firestore Collection", + }, + }, }), logLevel: defineString("LOG_LEVEL", { default: "info", diff --git a/kits/bigquery-firestore-export/tests/config.test.ts b/kits/bigquery-firestore-export/tests/config.test.ts index 69c670b53..1f76def72 100644 --- a/kits/bigquery-firestore-export/tests/config.test.ts +++ b/kits/bigquery-firestore-export/tests/config.test.ts @@ -15,8 +15,9 @@ */ import { Expression } from "firebase-functions/params"; +import type { SelectInput, TextInput } from "firebase-functions/params"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { CONFIG_EXPRESSIONS, configFromEnv } from "../src/config"; +import { CONFIG_EXPRESSIONS, configFromEnv, params } from "../src/config"; afterEach(() => { vi.unstubAllEnvs(); @@ -47,15 +48,115 @@ describe("configFromEnv", () => { vi.stubEnv("COLLECTION_PATH", "transferConfigs"); vi.stubEnv("LOG_LEVEL", "info"); - expect(configFromEnv()).toMatchObject({ + expect(configFromEnv()).toEqual({ projectId: "test-project", instanceId: "users-export", bigqueryDatasetLocation: "EU", datasetId: "analytics", tableName: "users", + queryString: "SELECT * FROM source.users", + displayName: "Users export", + schedule: "every 24 hours", + transferConfigName: undefined, + partitioningField: undefined, pubSubTopic: "kit-users-export-processMessages", firestoreCollection: "transferConfigs", logLevel: "info", }); }); }); + +const EXPECTED_DATASET_LOCATIONS = [ + "us-east5", + "us-central1", + "us-west4", + "us-west2", + "northamerica-northeast1", + "us-east4", + "us-west1", + "us-west3", + "southamerica-east1", + "southamerica-west1", + "us-east1", + "northamerica-northeast2", + "asia-south2", + "asia-east2", + "asia-southeast2", + "australia-southeast2", + "asia-south1", + "asia-northeast2", + "asia-northeast3", + "asia-southeast1", + "australia-southeast1", + "asia-east1", + "asia-northeast1", + "europe-west1", + "europe-north1", + "europe-west3", + "europe-west2", + "europe-southwest1", + "europe-west8", + "europe-west4", + "europe-west9", + "europe-central2", + "europe-west6", + "US", + "EU", +]; + +describe("COLLECTION_PATH input", () => { + const input = params.firestoreCollection.options.input as TextInput; + + test("declares the collection path validator and its error message", () => { + expect(input.text.validationRegex).toBe("^[^/]+(/[^/]+/[^/]+)*$"); + expect(input.text.validationErrorMessage).toBe( + "Must be a valid Cloud Firestore Collection" + ); + expect(params.firestoreCollection.options.default).toBe("transferConfigs"); + }); + + test.each([ + ["transferConfigs", true], + ["a", true], + ["a/b/c", true], + ["a/b/c/d/e", true], + ["a/b", false], + ["a/b/c/d", false], + ["/a", false], + ["a/", false], + ["", false], + ])("%s is accepted: %s", (path, accepted) => { + const regex = new RegExp(input.text.validationRegex as string); + expect(regex.test(path)).toBe(accepted); + }); +}); + +describe("BIGQUERY_DATASET_LOCATION input", () => { + const input = params.bigqueryDatasetLocation.options + .input as SelectInput; + + test("offers the upstream regions and both multi-regions", () => { + expect(input.select.options.map((option) => option.value)).toEqual( + EXPECTED_DATASET_LOCATIONS + ); + expect(params.bigqueryDatasetLocation.options.default).toBe("US"); + }); + + test("labels every option", () => { + for (const option of input.select.options) { + expect(option.label).toMatch(new RegExp(`\\(${option.value}\\)$`)); + } + }); +}); + +describe("params without upstream validation", () => { + test.each([ + "datasetId", + "tableName", + "queryString", + "displayName", + "schedule", + ] as const)("%s stays free-form", (key) => { + expect(params[key].options.input).toBeUndefined(); + }); +});