diff --git a/.firebaserc b/.firebaserc index 1821c66..d2dea10 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,7 @@ { "projects": { - "default": "motoapp-d22e0" + "default": "motoapp-d22e0", + "production": "motoapp-d22e0", + "staging": "motorsignal-staging" } } diff --git a/website/.env.example b/website/.env.example index 22b0b12..2ee1031 100644 --- a/website/.env.example +++ b/website/.env.example @@ -1 +1,37 @@ -MOTORSIGNAL_API_BASE_URL=https://api.motorsignal.app +# MotorSignal Website Environment Configuration +# +# Copy this file to .env.local for local development, or configure these +# variables in your deployment environment (Vercel, Docker, VPS, etc.). +# +# Production Firebase project: motoapp-d22e0 +# Staging Firebase project: motorsignal-staging +# +# DO NOT commit real secrets or production credentials to version control. + +# Environment descriptor: "production", "staging", or "development" +MOTORSIGNAL_ENV=staging + +# Site URLs +NEXT_PUBLIC_SITE_URL=https://staging.motorsignal.app +MOTORSIGNAL_API_BASE_URL=https://api-staging.motorsignal.app + +# Client-Side Firebase Configuration (Required for browser & SSR client) +NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=motorsignal-staging.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=motorsignal-staging +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=motorsignal-staging.firebasestorage.app +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-messaging-sender-id +NEXT_PUBLIC_FIREBASE_APP_ID=your-firebase-web-app-id + +# Server-Side Firebase Admin SDK Configuration +# Note: FIREBASE_PROJECT_ID must match NEXT_PUBLIC_FIREBASE_PROJECT_ID. +FIREBASE_PROJECT_ID=motorsignal-staging + +# Server-Side Firebase Service Account (Optional JSON string; if omitted, Google ADC is used) +# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"motorsignal-staging",...} + +# Local QA / Emulator Opt-In (Optional; only used for local test suites) +# NEXT_PUBLIC_FIREBASE_EMULATORS=false +# NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID=demo-motorsignal-qa +# NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT=9099 +# NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT=8088 diff --git a/website/lib/firebase-admin.ts b/website/lib/firebase-admin.ts index 53fc375..2045120 100644 --- a/website/lib/firebase-admin.ts +++ b/website/lib/firebase-admin.ts @@ -1,6 +1,7 @@ import { getApps, initializeApp, cert, type App } from 'firebase-admin/app'; import { getAuth, type Auth } from 'firebase-admin/auth'; import { getFirestore, type Firestore } from 'firebase-admin/firestore'; +import { getServerFirebaseProjectId } from './firebase-config'; /** * Server-only singleton initialization for Firebase Admin. @@ -9,12 +10,17 @@ import { getFirestore, type Firestore } from 'firebase-admin/firestore'; * - Singleton reuse across Next.js API route invocations. * - Server-only execution (never imported or bundled in client-side code). * - Fails closed if project configuration is missing. + * - Requires explicit FIREBASE_PROJECT_ID with no fallback to production. * - Supports Google Application Default Credentials (ADC), explicit service account * via FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS, and Firebase emulator. */ let _adminApp: App | null = null; +export function resetFirebaseAdminAppForTesting(): void { + _adminApp = null; +} + export function getFirebaseAdminApp(): App { if (_adminApp) { return _adminApp; @@ -26,7 +32,11 @@ export function getFirebaseAdminApp(): App { return _adminApp; } - const projectId = process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0'; + const projectId = getServerFirebaseProjectId(); + if (process.env.NEXT_PUBLIC_FIREBASE_EMULATORS === 'true' || projectId.startsWith('demo-')) { + process.env.FIREBASE_AUTH_EMULATOR_HOST = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099'; + process.env.FIRESTORE_EMULATOR_HOST = process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8088'; + } const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT; let credential; diff --git a/website/lib/firebase-client.ts b/website/lib/firebase-client.ts index 0c298a7..f30b2d9 100644 --- a/website/lib/firebase-client.ts +++ b/website/lib/firebase-client.ts @@ -1,6 +1,7 @@ import { getApp, getApps, initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth'; import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore'; +import { getFirebaseClientConfig } from './firebase-config'; // Explicit local QA opt-in. A separate app/project prevents reusing a rider's // production session, and loopback endpoints cannot send fixtures to production. @@ -20,14 +21,9 @@ const existingQaApp = getApps().find((candidate) => candidate.name === 'motorsig const app = useEmulators ? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa') - : getApps().some((candidate) => candidate.name === '[DEFAULT]') ? getApp() : initializeApp({ - apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg', - authDomain: 'motoapp-d22e0.firebaseapp.com', - projectId: 'motoapp-d22e0', - storageBucket: 'motoapp-d22e0.firebasestorage.app', - messagingSenderId: '809431485362', - appId: '1:809431485362:web:06fdcd9ca12ff9342472ab', -}); + : getApps().some((candidate) => candidate.name === '[DEFAULT]') + ? getApp() + : initializeApp(getFirebaseClientConfig()); export const firebaseAuth = getAuth(app); export const googleProvider = new GoogleAuthProvider(); diff --git a/website/lib/firebase-config.ts b/website/lib/firebase-config.ts new file mode 100644 index 0000000..19dd2ed --- /dev/null +++ b/website/lib/firebase-config.ts @@ -0,0 +1,156 @@ +/** + * MotorSignal Firebase Configuration and Isolation Guards + * + * Enforces strict environment separation between production (motoapp-d22e0) + * and staging (motorsignal-staging), preventing staging environments from + * accidentally initializing against production Firebase resources. + */ + +export const PRODUCTION_FIREBASE_PROJECT_ID = 'motoapp-d22e0'; +export const STAGING_FIREBASE_PROJECT_ID = 'motorsignal-staging'; + +export interface FirebaseClientOptions { + apiKey: string; + authDomain: string; + projectId: string; + storageBucket: string; + messagingSenderId?: string; + appId?: string; +} + +/** + * Determines whether the current execution context is a staging deployment. + * Inspects standard environment variables and browser hostname. + */ +export function isStagingEnvironment(): boolean { + // Check standard environment descriptors + const motorsignalEnv = (process.env.MOTORSIGNAL_ENV || '').toLowerCase().trim(); + const appEnv = (process.env.APP_ENV || process.env.NEXT_PUBLIC_APP_ENV || '').toLowerCase().trim(); + + // If explicitly flagged as production, it is never staging + if (motorsignalEnv === 'production' || appEnv === 'production') { + return false; + } + + // Explicit staging flags + if (motorsignalEnv === 'staging' || appEnv === 'staging') { + return true; + } + + if (process.env.STAGING === 'true' || process.env.NEXT_PUBLIC_STAGING === 'true') { + return true; + } + + const nodeEnv = (process.env.NODE_ENV || '').toLowerCase().trim(); + if (nodeEnv === 'staging') { + return true; + } + + // Staging API base URL indicator (e.g. https://api-staging.motorsignal.app) + const apiBaseUrl = (process.env.MOTORSIGNAL_API_BASE_URL || '').toLowerCase(); + if (apiBaseUrl.includes('api-staging.motorsignal.app') || apiBaseUrl.includes('//staging.')) { + return true; + } + + // Staging site URL indicator (e.g. https://staging.motorsignal.app) + const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || '').toLowerCase(); + if (siteUrl.includes('staging.motorsignal.app')) { + return true; + } + + // Browser hostname inspection + if (typeof window !== 'undefined' && window.location?.hostname) { + const hostname = window.location.hostname.toLowerCase(); + if (hostname === 'staging.motorsignal.app') { + return true; + } + } + + return false; +} + +/** + * Staging safety guard: strictly prevents any staging deployment from + * initializing Firebase against the production project (motoapp-d22e0). + */ +export function assertStagingSafety(projectId: string): void { + if (isStagingEnvironment() && projectId === PRODUCTION_FIREBASE_PROJECT_ID) { + throw new Error( + `Staging safety guard violation: Staging deployment cannot initialize Firebase against production project "${PRODUCTION_FIREBASE_PROJECT_ID}". ` + + `Expected staging project "${STAGING_FIREBASE_PROJECT_ID}".` + ); + } +} + +/** + * Validates that server-side and browser-side Firebase project IDs agree. + * Throws if both are provided but do not match. + */ +export function assertProjectIdsMatch(clientProjectId?: string, serverProjectId?: string): void { + if (clientProjectId && serverProjectId && clientProjectId !== serverProjectId) { + throw new Error( + `Firebase project ID mismatch: Client project ID "${clientProjectId}" ` + + `does not match server project ID "${serverProjectId}".` + ); + } +} + +/** + * Validates and retrieves the client-side Firebase configuration options + * from environment variables. + * + * Throws if required configuration is missing or if the staging safety guard is violated. + */ +export function getFirebaseClientConfig(): FirebaseClientOptions { + const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY; + const authDomain = process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN; + const projectId = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID; + const storageBucket = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET; + const messagingSenderId = process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID; + const appId = process.env.NEXT_PUBLIC_FIREBASE_APP_ID; + + if (!projectId || !apiKey) { + throw new Error( + 'Missing required Firebase client configuration: NEXT_PUBLIC_FIREBASE_PROJECT_ID ' + + 'and NEXT_PUBLIC_FIREBASE_API_KEY must be set.' + ); + } + + // Enforce staging safety guard + assertStagingSafety(projectId); + + // Enforce client/server project ID consistency if server env var is visible + if (process.env.FIREBASE_PROJECT_ID) { + assertProjectIdsMatch(projectId, process.env.FIREBASE_PROJECT_ID); + } + + return { + apiKey, + authDomain: authDomain || `${projectId}.firebaseapp.com`, + projectId, + storageBucket: storageBucket || `${projectId}.firebasestorage.app`, + ...(messagingSenderId ? { messagingSenderId } : {}), + ...(appId ? { appId } : {}), + }; +} + +/** + * Validates and retrieves the server-side Firebase project ID. + * Requires explicit FIREBASE_PROJECT_ID; fails closed without falling back to production. + */ +export function getServerFirebaseProjectId(): string { + const projectId = process.env.FIREBASE_PROJECT_ID; + if (!projectId) { + throw new Error('FIREBASE_PROJECT_ID is required for Firebase Admin SDK initialization.'); + } + + // Enforce staging safety guard + assertStagingSafety(projectId); + + // Enforce client/server project ID consistency if client env var is set + if (process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID) { + assertProjectIdsMatch(process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, projectId); + } + + return projectId; +} diff --git a/website/tests/firebase-client-emulator.test.ts b/website/tests/firebase-client-emulator.test.ts index cfcaf27..899d1e0 100644 --- a/website/tests/firebase-client-emulator.test.ts +++ b/website/tests/firebase-client-emulator.test.ts @@ -14,12 +14,22 @@ vi.mock('firebase/firestore', () => ({ getFirestore: vi.fn(() => ({})), connectF describe('isolated Firebase QA client', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.getApps.mockReturnValue([]); }); afterEach(() => vi.unstubAllEnvs()); - it('keeps production defaults without explicit opt-in', async () => { + it('initializes production project when explicitly configured', async () => { vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'false'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', 'motoapp-d22e0'); + vi.stubEnv('FIREBASE_PROJECT_ID', 'motoapp-d22e0'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'test-production-api-key'); await import('../lib/firebase-client'); expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'motoapp-d22e0' })); expect(mocks.connectAuth).not.toHaveBeenCalled(); }); + it('rejects unconfigured Firebase client when emulators are off', async () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'false'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', ''); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', ''); + await expect(import('../lib/firebase-client')).rejects.toThrow('Missing required Firebase client configuration'); + expect(mocks.initializeApp).not.toHaveBeenCalled(); + }); it('rejects a production project before initializing Firebase', async () => { vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true'); vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'motoapp-d22e0'); diff --git a/website/tests/firebase-staging-isolation.test.ts b/website/tests/firebase-staging-isolation.test.ts new file mode 100644 index 0000000..a7e2e40 --- /dev/null +++ b/website/tests/firebase-staging-isolation.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + assertProjectIdsMatch, + assertStagingSafety, + getFirebaseClientConfig, + getServerFirebaseProjectId, + isStagingEnvironment, + PRODUCTION_FIREBASE_PROJECT_ID, + STAGING_FIREBASE_PROJECT_ID, +} from '../lib/firebase-config'; +import { + getFirebaseAdminApp, + resetFirebaseAdminAppForTesting, +} from '../lib/firebase-admin'; + +// Mock firebase-admin/app to spy on initializeApp without external network calls +const adminMocks = vi.hoisted(() => ({ + initializeApp: vi.fn((options: any) => ({ + name: '[DEFAULT]', + options, + })), + getApps: vi.fn((): any[] => []), + cert: vi.fn((certObj: any) => ({ cert: certObj })), +})); + +vi.mock('firebase-admin/app', () => adminMocks); + +describe('Firebase Staging Isolation & Configuration Guards', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + resetFirebaseAdminAppForTesting(); + adminMocks.getApps.mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('1. Missing Firebase Configuration', () => { + it('throws when NEXT_PUBLIC_FIREBASE_PROJECT_ID is missing on client', () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', ''); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'some-key'); + vi.stubEnv('FIREBASE_PROJECT_ID', ''); + + expect(() => getFirebaseClientConfig()).toThrow( + /Missing required Firebase client configuration/ + ); + }); + + it('throws when NEXT_PUBLIC_FIREBASE_API_KEY is missing on client', () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', ''); + vi.stubEnv('FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + + expect(() => getFirebaseClientConfig()).toThrow( + /Missing required Firebase client configuration/ + ); + }); + + it('does not silently select production when client config is missing', () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', ''); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', ''); + vi.stubEnv('FIREBASE_PROJECT_ID', ''); + + let clientConfig: any = null; + try { + clientConfig = getFirebaseClientConfig(); + } catch { + // Expected throw + } + expect(clientConfig).toBeNull(); + }); + + it('throws when FIREBASE_PROJECT_ID is missing on server', () => { + vi.stubEnv('FIREBASE_PROJECT_ID', ''); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', ''); + + expect(() => getServerFirebaseProjectId()).toThrow( + /FIREBASE_PROJECT_ID is required for Firebase Admin SDK initialization/ + ); + }); + + it('server initialization fails closed rather than falling back to motoapp-d22e0', () => { + vi.stubEnv('FIREBASE_PROJECT_ID', ''); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', ''); + + expect(() => getFirebaseAdminApp()).toThrow( + /FIREBASE_PROJECT_ID is required for Firebase Admin SDK initialization/ + ); + expect(adminMocks.initializeApp).not.toHaveBeenCalled(); + }); + }); + + describe('2. Valid Staging Configuration', () => { + beforeEach(() => { + vi.stubEnv('MOTORSIGNAL_ENV', 'staging'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'staging-api-key-123'); + vi.stubEnv('FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://staging.motorsignal.app'); + vi.stubEnv('MOTORSIGNAL_API_BASE_URL', 'https://api-staging.motorsignal.app'); + }); + + it('returns valid staging configuration for client SDK', () => { + const config = getFirebaseClientConfig(); + + expect(config.projectId).toBe(STAGING_FIREBASE_PROJECT_ID); + expect(config.apiKey).toBe('staging-api-key-123'); + expect(config.authDomain).toBe(`${STAGING_FIREBASE_PROJECT_ID}.firebaseapp.com`); + expect(config.storageBucket).toBe(`${STAGING_FIREBASE_PROJECT_ID}.firebasestorage.app`); + }); + + it('returns valid staging project ID for server Admin SDK', () => { + const serverProjectId = getServerFirebaseProjectId(); + expect(serverProjectId).toBe(STAGING_FIREBASE_PROJECT_ID); + }); + + it('initializes Firebase Admin with the staging project ID', () => { + const app = getFirebaseAdminApp(); + expect(app).toBeDefined(); + expect(adminMocks.initializeApp).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: STAGING_FIREBASE_PROJECT_ID, + }) + ); + }); + + it('supports custom authDomain and storageBucket overrides if provided', () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN', 'custom-staging-auth.motorsignal.app'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET', 'custom-staging-bucket'); + + const config = getFirebaseClientConfig(); + expect(config.authDomain).toBe('custom-staging-auth.motorsignal.app'); + expect(config.storageBucket).toBe('custom-staging-bucket'); + }); + }); + + describe('3. Mismatched Client / Server Project IDs', () => { + it('rejects mismatch where client is staging and server is production', () => { + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'staging-key'); + vi.stubEnv('FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + + expect(() => getFirebaseClientConfig()).toThrow( + /Firebase project ID mismatch/ + ); + expect(() => getServerFirebaseProjectId()).toThrow( + /Firebase project ID mismatch/ + ); + }); + + it('rejects mismatch where client is production and server is staging', () => { + vi.stubEnv('MOTORSIGNAL_ENV', 'production'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'prod-key'); + vi.stubEnv('FIREBASE_PROJECT_ID', STAGING_FIREBASE_PROJECT_ID); + + expect(() => getFirebaseClientConfig()).toThrow( + /Firebase project ID mismatch/ + ); + expect(() => getServerFirebaseProjectId()).toThrow( + /Firebase project ID mismatch/ + ); + }); + + it('assertProjectIdsMatch throws specific error describing the discrepancy', () => { + expect(() => + assertProjectIdsMatch(STAGING_FIREBASE_PROJECT_ID, PRODUCTION_FIREBASE_PROJECT_ID) + ).toThrow( + `Firebase project ID mismatch: Client project ID "${STAGING_FIREBASE_PROJECT_ID}" does not match server project ID "${PRODUCTION_FIREBASE_PROJECT_ID}".` + ); + }); + }); + + describe('4. Production Project Rejected in Staging', () => { + it('rejects motoapp-d22e0 when MOTORSIGNAL_ENV=staging', () => { + vi.stubEnv('MOTORSIGNAL_ENV', 'staging'); + expect(isStagingEnvironment()).toBe(true); + + expect(() => assertStagingSafety(PRODUCTION_FIREBASE_PROJECT_ID)).toThrow( + /Staging safety guard violation/ + ); + }); + + it('rejects motoapp-d22e0 when APP_ENV=staging', () => { + vi.stubEnv('APP_ENV', 'staging'); + expect(isStagingEnvironment()).toBe(true); + + expect(() => assertStagingSafety(PRODUCTION_FIREBASE_PROJECT_ID)).toThrow( + /Staging safety guard violation/ + ); + }); + + it('rejects motoapp-d22e0 when NEXT_PUBLIC_SITE_URL is staging domain', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://staging.motorsignal.app'); + expect(isStagingEnvironment()).toBe(true); + + expect(() => assertStagingSafety(PRODUCTION_FIREBASE_PROJECT_ID)).toThrow( + /Staging safety guard violation/ + ); + }); + + it('rejects motoapp-d22e0 when MOTORSIGNAL_API_BASE_URL is staging API', () => { + vi.stubEnv('MOTORSIGNAL_API_BASE_URL', 'https://api-staging.motorsignal.app'); + expect(isStagingEnvironment()).toBe(true); + + expect(() => assertStagingSafety(PRODUCTION_FIREBASE_PROJECT_ID)).toThrow( + /Staging safety guard violation/ + ); + }); + + it('client config throws staging violation when staging environment specifies production project', () => { + vi.stubEnv('MOTORSIGNAL_ENV', 'staging'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'prod-key'); + + expect(() => getFirebaseClientConfig()).toThrow(/Staging safety guard violation/); + }); + + it('server project ID throws staging violation when staging environment specifies production project', () => { + vi.stubEnv('MOTORSIGNAL_ENV', 'staging'); + vi.stubEnv('FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + + expect(() => getServerFirebaseProjectId()).toThrow(/Staging safety guard violation/); + }); + }); + + describe('5. Existing Production Behavior When Explicitly Configured', () => { + beforeEach(() => { + vi.stubEnv('MOTORSIGNAL_ENV', 'production'); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_FIREBASE_API_KEY', 'prod-api-key-999'); + vi.stubEnv('FIREBASE_PROJECT_ID', PRODUCTION_FIREBASE_PROJECT_ID); + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://motorsignal.app'); + vi.stubEnv('MOTORSIGNAL_API_BASE_URL', 'https://api.motorsignal.app'); + }); + + it('allows motoapp-d22e0 when explicitly configured in production', () => { + expect(isStagingEnvironment()).toBe(false); + expect(() => assertStagingSafety(PRODUCTION_FIREBASE_PROJECT_ID)).not.toThrow(); + }); + + it('returns production client configuration', () => { + const config = getFirebaseClientConfig(); + expect(config.projectId).toBe(PRODUCTION_FIREBASE_PROJECT_ID); + expect(config.apiKey).toBe('prod-api-key-999'); + expect(config.authDomain).toBe(`${PRODUCTION_FIREBASE_PROJECT_ID}.firebaseapp.com`); + expect(config.storageBucket).toBe(`${PRODUCTION_FIREBASE_PROJECT_ID}.firebasestorage.app`); + }); + + it('returns production server project ID', () => { + expect(getServerFirebaseProjectId()).toBe(PRODUCTION_FIREBASE_PROJECT_ID); + }); + + it('initializes Firebase Admin with production project ID', () => { + const app = getFirebaseAdminApp(); + expect(app).toBeDefined(); + expect(adminMocks.initializeApp).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: PRODUCTION_FIREBASE_PROJECT_ID, + }) + ); + }); + + it('supports FIREBASE_SERVICE_ACCOUNT JSON credentials in production', () => { + const fakeSa = { + type: 'service_account', + project_id: PRODUCTION_FIREBASE_PROJECT_ID, + private_key: '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASC...\n-----END PRIVATE KEY-----\n', + client_email: 'firebase-adminsdk@motoapp-d22e0.iam.gserviceaccount.com', + }; + vi.stubEnv('FIREBASE_SERVICE_ACCOUNT', JSON.stringify(fakeSa)); + + getFirebaseAdminApp(); + expect(adminMocks.cert).toHaveBeenCalledWith(fakeSa); + expect(adminMocks.initializeApp).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: PRODUCTION_FIREBASE_PROJECT_ID, + credential: expect.anything(), + }) + ); + }); + + it('fails safely if FIREBASE_SERVICE_ACCOUNT contains invalid JSON', () => { + vi.stubEnv('FIREBASE_SERVICE_ACCOUNT', '{invalid-json'); + + expect(() => getFirebaseAdminApp()).toThrow( + /Invalid Firebase service account configuration/ + ); + }); + }); +}); diff --git a/website/vitest.config.ts b/website/vitest.config.ts index 68a4989..9e12347 100644 --- a/website/vitest.config.ts +++ b/website/vitest.config.ts @@ -5,5 +5,10 @@ export default defineConfig({ environment: 'jsdom', globals: true, include: ['tests/**/*.test.tsx', 'tests/**/*.test.ts'], + env: { + NEXT_PUBLIC_FIREBASE_API_KEY: 'test-staging-api-key', + NEXT_PUBLIC_FIREBASE_PROJECT_ID: 'motorsignal-staging', + FIREBASE_PROJECT_ID: 'motorsignal-staging', + }, }, });