Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
308 changes: 308 additions & 0 deletions cypress/e2e/user-feature-flags.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
/**
* User Feature Flags — Cypress E2E tests
*
* What these tests cover:
* - md_features cookie is set as httpOnly after login
* - Cookie payload contains the flags returned by GET /v1/user
* - Cookie is cleared when the user logs out
* - Feature flags are refreshed on session renewal (hourly, driven by
* AuthSessionProvider → setUserCookieSession → refreshUserFeatureFlags)
* - window.__featureFlags (UserFeatureFlagProvider state) matches the
* resolved flags after every login, renewal, and logout transition
*
* What these tests do NOT cover (use Jest + RTL instead):
* - HMAC signature correctness — that is a unit test for sign()/verify()
* in src/app/actions/feature-flags.ts.
*
* Provider state assertions:
* UserFeatureFlagProvider exposes its live state on window.__featureFlags
* when window.Cypress is set (mirrors the window.store pattern in store.ts).
* Use `cy.window().its('__featureFlags')` to assert provider values directly.
*
* Session renewal helper:
* Combine them to simulate the
* AuthSessionProvider interval firing with a stale session.
*
* Cookie format: "<base64url(JSON.stringify(features))>.<base64url(hmac)>"
* The payload (first segment) is readable without the secret.
*/

const TEST_EMAIL = 'featureFlagsTest@mobilitydata.org';
const TEST_PASSWORD = 'IloveOrangeCones123!';

/** Minimal UserProfile body for GET /v1/user mocks. */
function mockUserProfile(
features: Array<{ id: string; value_type: string; value: unknown }> = [],
) {
return {
id: 'test-uid',
email: TEST_EMAIL,
full_name: 'Test User',
legacy_org_name: 'Test Organization', // required for isRegistered: true
email_verified: true,
is_registered_to_receive_api_announcements: false,
features,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
}

/**
* Decode the feature flags stored in the cookie payload.
* The cookie is "<base64url(payload)>.<base64url(hmac)>".
* We read only the payload — no secret needed.
*/
function decodeCookiePayload(
cookieValue: string,
): Array<{ id: string; value_type: string; value: unknown }> {
const encoded = cookieValue.split('.')[0];
// base64url → standard base64 before atob()
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
}

type CypressWindow = Window & {
store: { dispatch: (a: unknown) => void };
__featureFlags?: Record<string, unknown>;
};

/** Dispatch the login saga and wait for POST /api/feature-flags to complete. */
function loginViaSaga(alias: `@${string}`) {
cy.window().then((win) => {
// Dispatching 'userProfile/login' triggers emailLoginSaga, which calls
// signInWithEmailAndPassword (Firebase emulator), GET /v1/user, and
// POST /api/feature-flags (applyUserFeatureFlags) before dispatching loginSuccess.
(win as unknown as CypressWindow).store.dispatch({
type: 'userProfile/login',
payload: { email: TEST_EMAIL, password: TEST_PASSWORD },
});
});
cy.wait(alias);
}

// ---------------------------------------------------------------------------

describe('User Feature Flags', () => {
beforeEach(() => {
// Create a fresh user in the Firebase emulator before each test.
cy.createNewUserAndSignIn(TEST_EMAIL, TEST_PASSWORD);
cy.visit('/');
});

// -------------------------------------------------------------------------
// Login
// -------------------------------------------------------------------------
describe('on login', () => {
it('sets the md_features cookie as httpOnly', () => {
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([
{ id: 'isNotificationsEnabled', value_type: 'boolean', value: true },
]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlags');

loginViaSaga('@setFlags');

cy.getCookie('md_features')
.should('exist')
.and('have.property', 'httpOnly', true);

// Provider state should reflect the resolved flags.
cy.window()
.its('__featureFlags')
.should('deep.include', { isNotificationsEnabled: true });
});

it('cookie payload contains the flags returned by the API', () => {
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([
{ id: 'isNotificationsEnabled', value_type: 'boolean', value: true },
{
id: 'isSealOfReliabilityFilterEnabled',
value_type: 'boolean',
value: false,
},
]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlags');

loginViaSaga('@setFlags');

cy.getCookie('md_features').then((cookie) => {
cy.wrap(cookie).should('not.be.null');
const flags = decodeCookiePayload(cookie!.value);
cy.wrap(flags.find((f) => f.id === 'isNotificationsEnabled')?.value).should('equal', true);
cy.wrap(
flags.find((f) => f.id === 'isSealOfReliabilityFilterEnabled')?.value,
).should('equal', false);
});

cy.window().its('__featureFlags').should('deep.equal', {
isNotificationsEnabled: true,
isSealOfReliabilityFilterEnabled: false,
});
});

it('cookie stores an empty array when the API returns no flags', () => {
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlags');

loginViaSaga('@setFlags');

cy.getCookie('md_features').then((cookie) => {
cy.wrap(cookie).should('not.be.null');
const flags = decodeCookiePayload(cookie!.value);
// Raw cookie stores the API response. toUserFeatureFlags() fills in
// defaults on read — the provider always falls back to defaultUserFeatureFlags.
cy.wrap(flags).should('deep.equal', []);
});

// Provider fills in defaults for all missing flags.
cy.window().its('__featureFlags').should('deep.equal', {
isNotificationsEnabled: false,
isSealOfReliabilityFilterEnabled: false,
});
});
});

// -------------------------------------------------------------------------
// Logout
// -------------------------------------------------------------------------
// -------------------------------------------------------------------------
describe('on logout', () => {
beforeEach(() => {
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([
{ id: 'isNotificationsEnabled', value_type: 'boolean', value: true },
]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlags');

loginViaSaga('@setFlags');
cy.getCookie('md_features').should('exist');
});

it('clears the md_features cookie', () => {
// Navigate to the account page where the sign-out button is accessible.
cy.visit('/account');
cy.get('[data-cy="desktop-signOutButton"]').click({ force: true });
cy.get('[data-cy="confirmSignOutButton"]').click();

cy.getCookie('md_features').should('be.null');

// Provider should be reset to defaults after logout.
cy.window().its('__featureFlags').should('deep.equal', {
isNotificationsEnabled: false,
isSealOfReliabilityFilterEnabled: false,
});
});

it('also clears the md_session cookie', () => {
// Sanity-check that both session cookies are cleared together.
cy.visit('/account');
cy.get('[data-cy="desktop-signOutButton"]').click({ force: true });
cy.get('[data-cy="confirmSignOutButton"]').click();

cy.getCookie('md_session').should('be.null');
cy.getCookie('md_features').should('be.null');

// Provider should be reset to defaults after logout.
cy.window().its('__featureFlags').should('deep.equal', {
isNotificationsEnabled: false,
isSealOfReliabilityFilterEnabled: false,
});
});
});
});

// -----------------------------------------------------------------------------
// Session renewal
//
// AuthSessionProvider registers a 5-minute setInterval on mount that calls
// setUserCookieSession(). When the session is stale (expiresAt exceeded, same
// uid), setUserCookieSession() returns wasRenewal=true and AuthSessionProvider
// calls refreshUserFeatureFlags(), which re-fetches GET /v1/user and writes a
// fresh md_features cookie via POST /api/feature-flags.
//
// cy.clock() MUST be called before cy.visit() so Sinon intercepts the
// setInterval registered by AuthSessionProvider on mount and cy.tick() can
// trigger its callback. Only intervals are faked — Date.now() and setTimeout
// are left real so Firebase SDK internals are unaffected.
// Backdating md_session_meta.expiresAt to 1 (ms since epoch) makes
// getSessionStatus() reliably return 'renewal' for any real Date.now() value.
// -----------------------------------------------------------------------------
describe('User Feature Flags — session renewal', () => {
beforeEach(() => {
cy.createNewUserAndSignIn(TEST_EMAIL, TEST_PASSWORD);
// Fake setInterval before visiting so cy.tick() controls the AuthSessionProvider
// interval. Leave Date.now() and setTimeout on real timers.
cy.clock(0, ['setInterval', 'clearInterval']);
cy.visit('/');
});

afterEach(() => {
cy.clock().invoke('restore');
});

it('re-fetches and applies updated feature flags when the session renews', () => {
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([
{ id: 'isNotificationsEnabled', value_type: 'boolean', value: false },
]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlagsLogin');
loginViaSaga('@setFlagsLogin');
cy.getCookie('md_features').should('exist');
cy.window()
.its('__featureFlags')
.should('deep.include', { isNotificationsEnabled: false });

// Backdate the session meta so getSessionStatus() returns 'renewal' on the
// next interval. expiresAt=1 is always in the past for any real Date.now().
cy.window().then((win) => {
const raw = win.localStorage.getItem('md_session_meta');
if (raw != null) {
const meta = JSON.parse(raw) as { uid: string; expiresAt: number };
win.localStorage.setItem(
'md_session_meta',
JSON.stringify({ ...meta, expiresAt: 1 }),
);
}
});

// New flags returned by the user service after renewal.
cy.intercept('GET', '**/v1/user', {
statusCode: 200,
body: mockUserProfile([
{ id: 'isNotificationsEnabled', value_type: 'boolean', value: true },
]),
});
cy.intercept('POST', '**/api/feature-flags').as('setFlagsRenewal');

// Advance one interval period. The AuthSessionProvider setInterval fires,
// sees the stale session, and calls refreshUserFeatureFlags() — the full
// production code path, with no service functions exposed on window.
cy.tick(5 * 60 * 1000 + 1);

cy.wait('@setFlagsRenewal');

cy.getCookie('md_features').then((cookie) => {
cy.wrap(cookie).should('not.be.null');
const flags = decodeCookiePayload(cookie!.value);
cy.wrap(
flags.find((f) => f.id === 'isNotificationsEnabled')?.value,
).should('equal', true);
});

cy.window()
.its('__featureFlags')
.should('deep.include', { isNotificationsEnabled: true });
});
});
Loading
Loading