fix(website): isolate Firebase configuration for staging and enforce safety guards

This commit is contained in:
Developer 2026-10-08 10:50:28 -04:00
parent bd278cdfd3
commit 602aa962e2
8 changed files with 520 additions and 12 deletions

View File

@ -1,5 +1,7 @@
{
"projects": {
"default": "motoapp-d22e0"
"default": "motoapp-d22e0",
"production": "motoapp-d22e0",
"staging": "motorsignal-staging"
}
}

View File

@ -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

View File

@ -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;

View File

@ -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();

View File

@ -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;
}

View File

@ -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');

View File

@ -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/
);
});
});
});

View File

@ -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',
},
},
});