Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { type ReactElement } from 'react';
import { NextIntlClientProvider, hasLocale } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { getRemoteConfigValues } from '../../lib/remote-config.server';
import { getRemoteConfigValuesForUser } from '../../lib/remote-config.server';
import { getCurrentUserFromCookie } from '../utils/auth-server';
import { Mulish, IBM_Plex_Mono } from 'next/font/google';
import Footer from '../components/Footer';
import Header from '../components/Header';
Expand Down Expand Up @@ -89,10 +90,11 @@ export default async function LocaleLayout({
// Enable static rendering for this locale
setRequestLocale(validLocale);

const [messages, remoteConfig] = await Promise.all([
const [messages, currentUser] = await Promise.all([
getMessages(),
getRemoteConfigValues(),
getCurrentUserFromCookie(),
]);
const remoteConfig = await getRemoteConfigValuesForUser(currentUser?.email);

return (
<html lang={validLocale}>
Expand Down
8 changes: 5 additions & 3 deletions src/app/screens/Feed/components/DataQualitySummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { WarningContentBox } from '../../../components/WarningContentBox';
import { FeedStatusChip } from '../../../components/FeedStatus';
import OfficialChip from '../../../components/OfficialChip';
import { getTranslations } from 'next-intl/server';
import { getRemoteConfigValues } from '../../../../lib/remote-config.server';
import { getRemoteConfigValuesForUser } from '../../../../lib/remote-config.server';
import { getCurrentUserFromCookie } from '../../../utils/auth-server';

export interface DataQualitySummaryProps {
feedStatus: components['schemas']['Feed']['status'];
Expand All @@ -21,11 +22,12 @@ export default async function DataQualitySummary({
isOfficialFeed,
latestDataset,
}: DataQualitySummaryProps): Promise<React.ReactElement> {
const [t, tCommon, config] = await Promise.all([
const [t, tCommon, currentUser] = await Promise.all([
getTranslations('feeds'),
getTranslations('common'),
getRemoteConfigValues(),
getCurrentUserFromCookie(),
]);
const config = await getRemoteConfigValuesForUser(currentUser?.email);

return (
<Box data-testid='data-quality-summary' sx={{ my: 2 }}>
Expand Down
156 changes: 156 additions & 0 deletions src/lib/remote-config.server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* @jest-environment node
*/

import {
getRemoteConfigValuesForUser,
refreshRemoteConfig,
} from './remote-config.server';
import { defaultRemoteConfigValues } from '../app/interface/RemoteConfig';

jest.mock('server-only', () => ({}));
jest.mock('react', () => ({
cache: (fn: unknown) => fn,
}));

const mockGetTemplate = jest.fn();

jest.mock('firebase-admin/remote-config', () => ({
getRemoteConfig: jest.fn(() => ({ getTemplate: mockGetTemplate })),
}));

jest.mock('../app/utils/config', () => ({
getEnvConfig: jest.fn().mockReturnValue(''),
}));

const mockIsMobilityDatabaseAdmin = jest.fn();

jest.mock('../app/utils/auth-server', () => ({
isMobilityDatabaseAdmin: (...args: unknown[]) =>
mockIsMobilityDatabaseAdmin(...args),
}));

jest.mock('./firebase-admin', () => ({
getFirebaseAdminApp: jest.fn().mockReturnValue({}),
}));

describe('remote-config.server', () => {
const originalEnv = process.env;

beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

describe('getRemoteConfigValuesForUser', () => {
it('returns base config for non-mobilitydata.org user in production', async () => {
process.env.VERCEL_ENV = 'production';
mockIsMobilityDatabaseAdmin.mockReturnValue(false);
mockGetTemplate.mockResolvedValue({ parameters: {} });
await refreshRemoteConfig();

const result = await getRemoteConfigValuesForUser('user@example.com');

expect(mockIsMobilityDatabaseAdmin).toHaveBeenCalledWith(
'user@example.com',
);
expect(result.enableMetrics).toBe(
defaultRemoteConfigValues.enableMetrics,
);
expect(result.enableLanguageToggle).toBe(
defaultRemoteConfigValues.enableLanguageToggle,
);
});

it('returns config with all boolean flags true for @mobilitydata.org user in production', async () => {
process.env.VERCEL_ENV = 'production';
mockIsMobilityDatabaseAdmin.mockReturnValue(true);
mockGetTemplate.mockResolvedValue({
parameters: {
enableMetrics: { defaultValue: { value: 'false' } },
enableLanguageToggle: { defaultValue: { value: 'false' } },
},
});
await refreshRemoteConfig();

const result = await getRemoteConfigValuesForUser(
'engineer@mobilitydata.org',
);

expect(mockIsMobilityDatabaseAdmin).toHaveBeenCalledWith(
'engineer@mobilitydata.org',
);
expect(result.enableMetrics).toBe(true);
expect(result.enableLanguageToggle).toBe(true);
expect(result.enableFeedStatusBadge).toBe(true);
expect(result.enableDetailedCoveredArea).toBe(true);
expect(result.gbfsValidator).toBe(true);
});

it('does not apply bypass for @mobilitydata.org user outside production', async () => {
process.env.VERCEL_ENV = 'preview';
mockIsMobilityDatabaseAdmin.mockReturnValue(true);
mockGetTemplate.mockResolvedValue({
parameters: {
enableMetrics: { defaultValue: { value: 'false' } },
},
});
await refreshRemoteConfig();

const result = await getRemoteConfigValuesForUser(
'engineer@mobilitydata.org',
);

// In non-production environments, bypass is NOT applied
expect(result.enableMetrics).toBe(false);
});

it('returns base config for undefined email in production', async () => {
process.env.VERCEL_ENV = 'production';
mockIsMobilityDatabaseAdmin.mockReturnValue(false);
mockGetTemplate.mockResolvedValue({ parameters: {} });
await refreshRemoteConfig();

const result = await getRemoteConfigValuesForUser(undefined);

expect(mockIsMobilityDatabaseAdmin).toHaveBeenCalledWith(undefined);
expect(result.enableMetrics).toBe(
defaultRemoteConfigValues.enableMetrics,
);
});

it('preserves non-boolean config values when applying bypass', async () => {
process.env.VERCEL_ENV = 'production';
mockIsMobilityDatabaseAdmin.mockReturnValue(true);
mockGetTemplate.mockResolvedValue({
parameters: {
gtfsMetricsBucketEndpoint: {
defaultValue: {
value: 'https://storage.googleapis.com/custom-gtfs-bucket',
},
},
visualizationMapFullDataLimit: {
defaultValue: { value: '10' },
},
},
});
await refreshRemoteConfig();

const result = await getRemoteConfigValuesForUser(
'engineer@mobilitydata.org',
);

// Non-boolean values should be preserved from remote config
expect(result.gtfsMetricsBucketEndpoint).toBe(
'https://storage.googleapis.com/custom-gtfs-bucket',
);
expect(result.visualizationMapFullDataLimit).toBe(10);
// Boolean values should be overridden to true
expect(result.enableMetrics).toBe(true);
});
});
});
34 changes: 34 additions & 0 deletions src/lib/remote-config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
defaultRemoteConfigValues,
type RemoteConfigValues,
} from '../app/interface/RemoteConfig';
import { isMobilityDatabaseAdmin } from '../app/utils/auth-server';

/**
* Cache duration for Remote Config fetches (in seconds).
Expand Down Expand Up @@ -140,3 +141,36 @@ export async function refreshRemoteConfig(): Promise<RemoteConfigValues> {
cacheTimestamp = 0;
return await getRemoteConfigValues();
}

/**
* Returns a copy of the config with all boolean flags set to `true`.
* Used to give internal @mobilitydata.org users access to all features.
*/
function applyEmailBypass(config: RemoteConfigValues): RemoteConfigValues {
const overridden = { ...config };
for (const key of Object.keys(overridden) as Array<
keyof RemoteConfigValues
>) {
if (typeof overridden[key] === 'boolean') {
(overridden as Record<string, unknown>)[key] = true;
}
}
return overridden;
}

/**
* Get Remote Config values for a specific user.
* In production, @mobilitydata.org users receive all boolean feature flags enabled.
*/
export async function getRemoteConfigValuesForUser(
email?: string,
): Promise<RemoteConfigValues> {
const config = await getRemoteConfigValues();
if (
process.env.VERCEL_ENV === 'production' &&
isMobilityDatabaseAdmin(email)
) {
return applyEmailBypass(config);
}
return config;
}