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": {
|
"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 { getApps, initializeApp, cert, type App } from 'firebase-admin/app';
|
||||||
import { getAuth, type Auth } from 'firebase-admin/auth';
|
import { getAuth, type Auth } from 'firebase-admin/auth';
|
||||||
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
|
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
|
||||||
|
import { getServerFirebaseProjectId } from './firebase-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-only singleton initialization for Firebase Admin.
|
* 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.
|
* - Singleton reuse across Next.js API route invocations.
|
||||||
* - Server-only execution (never imported or bundled in client-side code).
|
* - Server-only execution (never imported or bundled in client-side code).
|
||||||
* - Fails closed if project configuration is missing.
|
* - 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
|
* - Supports Google Application Default Credentials (ADC), explicit service account
|
||||||
* via FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS, and Firebase emulator.
|
* via FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS, and Firebase emulator.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let _adminApp: App | null = null;
|
let _adminApp: App | null = null;
|
||||||
|
|
||||||
|
export function resetFirebaseAdminAppForTesting(): void {
|
||||||
|
_adminApp = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getFirebaseAdminApp(): App {
|
export function getFirebaseAdminApp(): App {
|
||||||
if (_adminApp) {
|
if (_adminApp) {
|
||||||
return _adminApp;
|
return _adminApp;
|
||||||
|
|
@ -26,7 +32,11 @@ export function getFirebaseAdminApp(): App {
|
||||||
return _adminApp;
|
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;
|
const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT;
|
||||||
|
|
||||||
let credential;
|
let credential;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getApp, getApps, initializeApp } from 'firebase/app';
|
import { getApp, getApps, initializeApp } from 'firebase/app';
|
||||||
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
|
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||||
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
|
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
|
// Explicit local QA opt-in. A separate app/project prevents reusing a rider's
|
||||||
// production session, and loopback endpoints cannot send fixtures to production.
|
// 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
|
const app = useEmulators
|
||||||
? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa')
|
? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa')
|
||||||
: getApps().some((candidate) => candidate.name === '[DEFAULT]') ? getApp() : initializeApp({
|
: getApps().some((candidate) => candidate.name === '[DEFAULT]')
|
||||||
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
|
? getApp()
|
||||||
authDomain: 'motoapp-d22e0.firebaseapp.com',
|
: initializeApp(getFirebaseClientConfig());
|
||||||
projectId: 'motoapp-d22e0',
|
|
||||||
storageBucket: 'motoapp-d22e0.firebasestorage.app',
|
|
||||||
messagingSenderId: '809431485362',
|
|
||||||
appId: '1:809431485362:web:06fdcd9ca12ff9342472ab',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const firebaseAuth = getAuth(app);
|
export const firebaseAuth = getAuth(app);
|
||||||
export const googleProvider = new GoogleAuthProvider();
|
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', () => {
|
describe('isolated Firebase QA client', () => {
|
||||||
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.getApps.mockReturnValue([]); });
|
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.getApps.mockReturnValue([]); });
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
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_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');
|
await import('../lib/firebase-client');
|
||||||
expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'motoapp-d22e0' }));
|
expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'motoapp-d22e0' }));
|
||||||
expect(mocks.connectAuth).not.toHaveBeenCalled();
|
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 () => {
|
it('rejects a production project before initializing Firebase', async () => {
|
||||||
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
|
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
|
||||||
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'motoapp-d22e0');
|
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',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['tests/**/*.test.tsx', 'tests/**/*.test.ts'],
|
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