Skip to content

Commit 97715bb

Browse files
authored
Mock Firebase in Storybook (#2125)
* Mocking out real firebase calls for YourLegislators component so Storybook doesn't make real API calls * Adding firebase mocks to prevent erroneous calls to a real Firebase instance from the Storybook server - includes a default state for auth calls and a note in the README about when to override
1 parent 53525de commit 97715bb

11 files changed

Lines changed: 413 additions & 10 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
const authModule = require("@firebase/auth")
2+
const {
3+
getStorybookAuthState,
4+
setStorybookAuthState,
5+
shouldAllowFirebaseCall,
6+
throwBlockedFirebaseCall
7+
} = require("./common")
8+
9+
function emitAuthState(callback) {
10+
const { user } = getStorybookAuthState()
11+
callback(user)
12+
}
13+
14+
function block(apiName) {
15+
return () => {
16+
throwBlockedFirebaseCall(
17+
"auth",
18+
apiName,
19+
"Mock auth state in your story providers or pass explicit props that avoid auth hooks."
20+
)
21+
}
22+
}
23+
24+
function onAuthStateChanged(auth, nextOrObserver, error, completed) {
25+
if (shouldAllowFirebaseCall()) {
26+
return authModule.onAuthStateChanged(auth, nextOrObserver, error, completed)
27+
}
28+
29+
const callback =
30+
typeof nextOrObserver === "function"
31+
? nextOrObserver
32+
: nextOrObserver?.next ?? (() => undefined)
33+
34+
emitAuthState(callback)
35+
36+
return () => undefined
37+
}
38+
39+
function patchAuthInstance(auth) {
40+
if (!auth || shouldAllowFirebaseCall()) return auth
41+
42+
return new Proxy(auth, {
43+
get(target, prop, receiver) {
44+
if (prop === "onAuthStateChanged") {
45+
return onAuthStateChanged
46+
}
47+
return Reflect.get(target, prop, receiver)
48+
}
49+
})
50+
}
51+
52+
function getAuth(...args) {
53+
const auth = authModule.getAuth(...args)
54+
return patchAuthInstance(auth)
55+
}
56+
57+
function initializeAuth(...args) {
58+
const auth = authModule.initializeAuth(...args)
59+
return patchAuthInstance(auth)
60+
}
61+
62+
module.exports = {
63+
...authModule,
64+
getAuth,
65+
initializeAuth,
66+
onAuthStateChanged,
67+
signInWithEmailAndPassword: block("signInWithEmailAndPassword"),
68+
signInWithPopup: block("signInWithPopup"),
69+
signInWithRedirect: block("signInWithRedirect"),
70+
signInAnonymously: block("signInAnonymously"),
71+
mockLoggedOutAuthState() {
72+
return setStorybookAuthState({ user: null })
73+
},
74+
mockLoggedInUserAuthState(overrides = {}) {
75+
const user = {
76+
uid: "storybook-user",
77+
email: "storybook-user@example.com",
78+
emailVerified: true,
79+
displayName: "Storybook User",
80+
isAnonymous: false,
81+
providerId: "firebase",
82+
photoURL: null,
83+
phoneNumber: null,
84+
tenantId: null,
85+
metadata: {
86+
creationTime: "",
87+
lastSignInTime: ""
88+
},
89+
providerData: [],
90+
refreshToken: "storybook-refresh-token",
91+
stsTokenManager: {
92+
accessToken: "storybook-access-token",
93+
refreshToken: "storybook-refresh-token",
94+
expirationTime: Date.now() + 60 * 60 * 1000
95+
},
96+
getIdToken: async () => "storybook-id-token",
97+
getIdTokenResult: async () => ({
98+
token: "storybook-id-token",
99+
authTime: "",
100+
issuedAtTime: "",
101+
expirationTime: "",
102+
signInProvider: null,
103+
claims: {
104+
role: "user",
105+
email_verified: true
106+
}
107+
}),
108+
reload: async () => undefined,
109+
delete: async () => undefined,
110+
toJSON: () => ({})
111+
}
112+
113+
return setStorybookAuthState({
114+
user: { ...user, ...overrides },
115+
claims: { role: "user", email_verified: true }
116+
})
117+
}
118+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const ALLOW_FLAG = "__MAPLE_STORYBOOK_ALLOW_FIREBASE__"
2+
const WARNED_FLAG = "__MAPLE_STORYBOOK_FIREBASE_WARNED__"
3+
const AUTH_STATE_KEY = "__MAPLE_STORYBOOK_FIREBASE_AUTH_STATE__"
4+
5+
function canAllowInRuntime() {
6+
if (typeof globalThis !== "undefined") {
7+
return Boolean(globalThis[ALLOW_FLAG])
8+
}
9+
return false
10+
}
11+
12+
function canAllowFromEnv() {
13+
return process.env.STORYBOOK_ALLOW_FIREBASE_CALLS === "1"
14+
}
15+
16+
function shouldAllowFirebaseCall() {
17+
return canAllowFromEnv() || canAllowInRuntime()
18+
}
19+
20+
function warnOnce(key, message) {
21+
if (typeof globalThis === "undefined") return
22+
23+
if (!globalThis[WARNED_FLAG]) {
24+
globalThis[WARNED_FLAG] = new Set()
25+
}
26+
27+
const warned = globalThis[WARNED_FLAG]
28+
if (!warned.has(key)) {
29+
warned.add(key)
30+
console.warn(message)
31+
}
32+
}
33+
34+
function throwBlockedFirebaseCall(service, apiName, helpText) {
35+
if (shouldAllowFirebaseCall()) return
36+
37+
const message = [
38+
`[Storybook Firebase Guard] Blocked ${service} call: ${apiName}`,
39+
"Real Firebase calls are disabled by default in Storybook.",
40+
"Mock the component data/hooks used by this story instead.",
41+
helpText,
42+
"To opt out temporarily for a specific story, set: parameters.firebaseGuard.allow = true",
43+
"To opt out globally, start Storybook with STORYBOOK_ALLOW_FIREBASE_CALLS=1"
44+
].join("\n")
45+
46+
warnOnce(`${service}:${apiName}`, message)
47+
throw new Error(message)
48+
}
49+
50+
function getStorybookAuthState() {
51+
if (typeof globalThis === "undefined") return { user: null }
52+
53+
return globalThis[AUTH_STATE_KEY] ?? { user: null }
54+
}
55+
56+
function setStorybookAuthState(state) {
57+
if (typeof globalThis !== "undefined") {
58+
globalThis[AUTH_STATE_KEY] = state
59+
}
60+
return state
61+
}
62+
63+
module.exports = {
64+
getStorybookAuthState,
65+
setStorybookAuthState,
66+
shouldAllowFirebaseCall,
67+
throwBlockedFirebaseCall
68+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const firestore = require("@firebase/firestore")
2+
const { throwBlockedFirebaseCall } = require("./common")
3+
4+
function block(apiName) {
5+
return () => {
6+
throwBlockedFirebaseCall(
7+
"firestore",
8+
apiName,
9+
"Provide mocked query results or inject mock props into the component under test."
10+
)
11+
}
12+
}
13+
14+
module.exports = {
15+
...firestore,
16+
getDoc: block("getDoc"),
17+
getDocs: block("getDocs"),
18+
onSnapshot: block("onSnapshot"),
19+
addDoc: block("addDoc"),
20+
setDoc: block("setDoc"),
21+
updateDoc: block("updateDoc"),
22+
deleteDoc: block("deleteDoc"),
23+
runTransaction: block("runTransaction"),
24+
getCountFromServer: block("getCountFromServer")
25+
}

.storybook/main.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/**
22
* @type {import('@storybook/react/types').StorybookConfig}
33
*/
4+
const path = require("path")
5+
46
module.exports = {
57
stories: [
68
"../stories/**/*.stories.mdx",
@@ -24,6 +26,17 @@ module.exports = {
2426
use: ["file-loader"]
2527
})
2628
config.resolve.fallback = { fs: false, path: false }
29+
config.resolve.alias = {
30+
...(config.resolve.alias || {}),
31+
"firebase/firestore$": path.resolve(
32+
__dirname,
33+
"./firebase-guards/firestore.guard.js"
34+
),
35+
"firebase/auth$": path.resolve(
36+
__dirname,
37+
"./firebase-guards/auth.guard.js"
38+
)
39+
}
2740
return config
2841
},
2942

.storybook/preview.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import React, { Suspense } from "react"
99
import { I18nextProvider } from "react-i18next"
1010
// import i18n from "i18next"
1111
import i18n from "./i18n"
12+
const { mockLoggedOutAuthState } = require("./firebase-guards/auth.guard.js")
13+
14+
mockLoggedOutAuthState()
1215

1316
export const parameters = {
1417
actions: { argTypesRegex: "^on[A-Z].*" },
@@ -60,6 +63,12 @@ export const parameters = {
6063

6164
export const decorators = [
6265
(Story, context) => {
66+
if (typeof window !== "undefined") {
67+
window.__MAPLE_STORYBOOK_ALLOW_FIREBASE__ = Boolean(
68+
context?.parameters?.firebaseGuard?.allow
69+
)
70+
}
71+
6372
return (
6473
<Suspense fallback="Loading...">
6574
<I18nextProvider i18n={i18n}>

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ git pull upstream main
6868
- `yarn dev:down`: Stop the application.
6969
- `yarn dev:update`: Update the application images. Run this whenever dependencies in `package.json` change.
7070

71+
### Storybook Firebase Guard
72+
73+
Storybook blocks real Firebase Auth and Firestore calls by default to prevent accidental network access from stories.
74+
75+
If a story triggers a blocked call, Storybook throws an error with guidance about what to mock.
76+
77+
Use these patterns when building stories:
78+
79+
- Prefer passing mock props/data to components instead of rendering hook-driven containers.
80+
- If a component supports injection (for example, mock `profile`/`index` props), use that in the story args.
81+
- For auth-driven stories, use the helpers in [stories/utils/storybookFirebaseAuth.ts](stories/utils/storybookFirebaseAuth.ts) to switch between logged-out and logged-in user mocks.
82+
83+
Opt-out options:
84+
85+
- Per story: set `parameters.firebaseGuard.allow = true`.
86+
- Globally: run Storybook with `STORYBOOK_ALLOW_FIREBASE_CALLS=1`.
87+
7188
Install the [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) and [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) browser extensions if you're developing frontend
7289

7390
## Contributing Backend Features to Dev/Prod:

components/EditProfilePage/PersonalInfoTab.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Form, Row, Col, Button } from "../bootstrap"
44
import { Profile, ProfileHook } from "../db"
55
import Input from "../forms/Input"
66
import { TitledSectionCard } from "../shared"
7-
import { YourLegislators } from "./YourLegislators"
7+
import { YourLegislators, YourLegislatorsProps } from "./YourLegislators"
88
import { OrgCategory, OrgCategories } from "components/auth"
99
import { TooltipButton } from "components/buttons"
1010
import { useTranslation } from "next-i18next"
@@ -34,6 +34,7 @@ type Props = {
3434
setFormUpdated?: any
3535
className?: string
3636
isOrg?: boolean
37+
legislatorsProps?: YourLegislatorsProps
3738
}
3839

3940
async function updateProfile(
@@ -68,7 +69,8 @@ export function PersonalInfoTab({
6869
uid,
6970
className,
7071
setFormUpdated,
71-
isOrg
72+
isOrg,
73+
legislatorsProps
7274
}: Props) {
7375
const {
7476
register,
@@ -271,7 +273,7 @@ export function PersonalInfoTab({
271273
{!isOrg && (
272274
<TitledSectionCard>
273275
<h2>{t("legislator.yourLegislators")}</h2>
274-
<YourLegislators />
276+
<YourLegislators {...legislatorsProps} />
275277
</TitledSectionCard>
276278
)}
277279

components/EditProfilePage/YourLegislators.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { Col, Row } from "../bootstrap"
2+
import { MemberSearchIndex, ProfileHook } from "../db"
23
import { External } from "../links"
34
import { SelectLegislators } from "../ProfilePage/SelectLegislators"
45
import { useTranslation } from "next-i18next"
56

6-
export const YourLegislators = () => {
7+
export type YourLegislatorsProps = {
8+
index?: MemberSearchIndex
9+
profile?: ProfileHook
10+
}
11+
12+
export const YourLegislators = ({ index, profile }: YourLegislatorsProps) => {
713
const { t } = useTranslation("editProfile")
814
return (
915
<>
1016
<Row className="mt-3 mb-3 gap-3">
1117
<Col xs={12} md={6} className="your-legislators-width">
12-
<SelectLegislators />
18+
<SelectLegislators index={index} profile={profile} />
1319
</Col>
1420
<Col className="bg-secondary text-white rounded d-flex justify-content-center align-items-center pt-4 your-legislators-width">
1521
<p className="flex-grow-1">

components/ProfilePage/SelectLegislators.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import {
88
import { Loading, Search } from "../legislatorSearch"
99
import { useTranslation } from "next-i18next"
1010

11-
export const SelectLegislators: React.FC<
12-
React.PropsWithChildren<unknown>
13-
> = () => {
11+
type SelectLegislatorsProps = {
12+
index?: MemberSearchIndex
13+
profile?: ProfileHook
14+
}
15+
16+
const SelectLegislatorsWithHooks = () => {
1417
const { index, loading: searchLoading } = useMemberSearch(),
1518
profile = useProfile(),
1619
loading = profile.loading || searchLoading
@@ -22,6 +25,16 @@ export const SelectLegislators: React.FC<
2225
)
2326
}
2427

28+
export const SelectLegislators: React.FC<
29+
React.PropsWithChildren<SelectLegislatorsProps>
30+
> = ({ index, profile }) => {
31+
if (index && profile) {
32+
return <LegislatorForm profile={profile} index={index} />
33+
}
34+
35+
return <SelectLegislatorsWithHooks />
36+
}
37+
2538
export const LegislatorForm: React.FC<
2639
React.PropsWithChildren<{
2740
index: MemberSearchIndex

0 commit comments

Comments
 (0)