fix(website): isolate Firebase configuration for staging and enforce safety guards
This commit is contained in:
parent
bd278cdfd3
commit
602aa962e2
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "motoapp-d22e0"
|
||||
"default": "motoapp-d22e0",
|
||||
"production": "motoapp-d22e0",
|
||||
"staging": "motorsignal-staging"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue