Skip to content

Commit cc29117

Browse files
authored
feat(auth): cache password policy per-app in validatePassword
avoids rate limiting errors by caching the policy, but handles per-app / per-tenant policies re-fetches the policy in case of validation errors, a caching strategy that perfectly mirrors firebase-js-sdk behavior
1 parent a300810 commit cc29117

File tree

5 files changed

+455
-26
lines changed

5 files changed

+455
-26
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/*
2+
* Copyright (c) 2016-present Invertase Limited & Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this library except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
19+
import { PasswordPolicyImpl } from '../lib/password-policy/PasswordPolicyImpl.js';
20+
import { PasswordPolicyMixin } from '../lib/password-policy/PasswordPolicyMixin.js';
21+
22+
const mockPasswordPolicy = {
23+
schemaVersion: 1,
24+
customStrengthOptions: {
25+
minPasswordLength: 8,
26+
maxPasswordLength: 100,
27+
containsLowercaseCharacter: true,
28+
containsUppercaseCharacter: true,
29+
containsNumericCharacter: true,
30+
containsNonAlphanumericCharacter: true,
31+
},
32+
allowedNonAlphanumericCharacters: ['!', '@', '#', '$', '%'],
33+
enforcementState: 'ENFORCE',
34+
};
35+
36+
describe('PasswordPolicyMixin', () => {
37+
describe('_getPasswordPolicyInternal', () => {
38+
it('should return project policy when tenantId is null', () => {
39+
const projectPolicy = new PasswordPolicyImpl(mockPasswordPolicy);
40+
const auth = {
41+
_tenantId: null,
42+
_projectPasswordPolicy: projectPolicy,
43+
_tenantPasswordPolicies: {},
44+
};
45+
Object.assign(auth, {
46+
_getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal,
47+
});
48+
49+
const result = auth._getPasswordPolicyInternal();
50+
51+
expect(result).toBe(projectPolicy);
52+
});
53+
54+
it('should return tenant policy when tenantId is set', () => {
55+
const tenantPolicy = new PasswordPolicyImpl(mockPasswordPolicy);
56+
const auth = {
57+
_tenantId: 'tenant-1',
58+
_projectPasswordPolicy: null,
59+
_tenantPasswordPolicies: { 'tenant-1': tenantPolicy },
60+
};
61+
Object.assign(auth, {
62+
_getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal,
63+
});
64+
65+
const result = auth._getPasswordPolicyInternal();
66+
67+
expect(result).toBe(tenantPolicy);
68+
});
69+
70+
it('should return undefined when no policy is cached', () => {
71+
const auth = {
72+
_tenantId: null,
73+
_projectPasswordPolicy: null,
74+
_tenantPasswordPolicies: {},
75+
};
76+
Object.assign(auth, {
77+
_getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal,
78+
});
79+
80+
const result = auth._getPasswordPolicyInternal();
81+
82+
expect(result).toBeNull();
83+
});
84+
});
85+
});
86+
87+
describe('validatePassword (integration)', () => {
88+
let mockAuth;
89+
let mockFetchPasswordPolicy;
90+
91+
beforeEach(() => {
92+
mockFetchPasswordPolicy = jest.fn().mockResolvedValue(mockPasswordPolicy);
93+
94+
// Create mock auth with the mixin, but override _updatePasswordPolicy to use our mock
95+
mockAuth = {
96+
app: {
97+
name: '[DEFAULT]',
98+
options: { apiKey: 'test-api-key-default' },
99+
},
100+
_tenantId: null,
101+
_projectPasswordPolicy: null,
102+
_tenantPasswordPolicies: {},
103+
};
104+
105+
// Apply the real mixin methods
106+
Object.assign(mockAuth, {
107+
_getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal,
108+
_recachePasswordPolicy: PasswordPolicyMixin._recachePasswordPolicy,
109+
validatePassword: PasswordPolicyMixin.validatePassword,
110+
});
111+
112+
// Override _updatePasswordPolicy to use our mock fetch
113+
mockAuth._updatePasswordPolicy = async function () {
114+
const response = await mockFetchPasswordPolicy(this);
115+
const passwordPolicy = new PasswordPolicyImpl(response);
116+
if (this._tenantId === null) {
117+
this._projectPasswordPolicy = passwordPolicy;
118+
} else {
119+
this._tenantPasswordPolicies[this._tenantId] = passwordPolicy;
120+
}
121+
};
122+
});
123+
124+
afterEach(() => {
125+
jest.clearAllMocks();
126+
});
127+
128+
describe('caching behavior', () => {
129+
it('should fetch password policy on first call', async () => {
130+
await mockAuth.validatePassword('Password123$');
131+
132+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
133+
expect(mockFetchPasswordPolicy).toHaveBeenCalledWith(mockAuth);
134+
});
135+
136+
it('should use cached policy on subsequent calls for same auth instance', async () => {
137+
await mockAuth.validatePassword('Password123$');
138+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
139+
140+
await mockAuth.validatePassword('AnotherPassword1!');
141+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
142+
143+
await mockAuth.validatePassword('YetAnother1@');
144+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it('should cache at project level when tenantId is null', async () => {
148+
await mockAuth.validatePassword('Password123$');
149+
150+
expect(mockAuth._projectPasswordPolicy).not.toBeNull();
151+
expect(Object.keys(mockAuth._tenantPasswordPolicies).length).toBe(0);
152+
});
153+
154+
it('should cache separately per tenant', async () => {
155+
// First tenant
156+
mockAuth._tenantId = 'tenant-1';
157+
await mockAuth.validatePassword('Password123$');
158+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
159+
160+
// Same tenant should use cache
161+
await mockAuth.validatePassword('AnotherPassword1!');
162+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
163+
164+
// Different tenant should fetch again
165+
mockAuth._tenantId = 'tenant-2';
166+
await mockAuth.validatePassword('Password123$');
167+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
168+
169+
// Back to first tenant should use its cache
170+
mockAuth._tenantId = 'tenant-1';
171+
await mockAuth.validatePassword('Password123$');
172+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
173+
174+
// Verify both tenant policies are cached
175+
expect(mockAuth._tenantPasswordPolicies['tenant-1']).toBeDefined();
176+
expect(mockAuth._tenantPasswordPolicies['tenant-2']).toBeDefined();
177+
});
178+
179+
it('should keep project and tenant caches separate', async () => {
180+
// Project level (no tenant)
181+
await mockAuth.validatePassword('Password123$');
182+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
183+
184+
// Tenant level
185+
mockAuth._tenantId = 'tenant-1';
186+
await mockAuth.validatePassword('Password123$');
187+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
188+
189+
// Back to project level should use project cache
190+
mockAuth._tenantId = null;
191+
await mockAuth.validatePassword('Password123$');
192+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
193+
194+
// Verify both caches exist
195+
expect(mockAuth._projectPasswordPolicy).not.toBeNull();
196+
expect(mockAuth._tenantPasswordPolicies['tenant-1']).toBeDefined();
197+
});
198+
199+
it('should return correct validation status using cached policy', async () => {
200+
const status1 = await mockAuth.validatePassword('Password123$');
201+
expect(status1.isValid).toBe(true);
202+
203+
const status2 = await mockAuth.validatePassword('weak');
204+
expect(status2.isValid).toBe(false);
205+
206+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
207+
});
208+
});
209+
210+
describe('schema validation', () => {
211+
it('should throw error on unsupported schema version', async () => {
212+
const unsupportedPolicy = {
213+
...mockPasswordPolicy,
214+
schemaVersion: 2,
215+
};
216+
mockFetchPasswordPolicy.mockResolvedValueOnce(unsupportedPolicy);
217+
218+
await expect(mockAuth.validatePassword('Password123$')).rejects.toThrow(
219+
'auth/unsupported-password-policy-schema-version',
220+
);
221+
});
222+
223+
it('should accept schema version 1', async () => {
224+
const validPolicy = {
225+
...mockPasswordPolicy,
226+
schemaVersion: 1,
227+
};
228+
mockFetchPasswordPolicy.mockResolvedValueOnce(validPolicy);
229+
230+
const status = await mockAuth.validatePassword('Password123$');
231+
expect(status.isValid).toBe(true);
232+
});
233+
});
234+
235+
describe('cache invalidation', () => {
236+
it('should refresh cache when _recachePasswordPolicy is called with existing cache', async () => {
237+
// First call caches the policy
238+
await mockAuth.validatePassword('Password123$');
239+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1);
240+
241+
// Simulate cache invalidation
242+
await mockAuth._recachePasswordPolicy();
243+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
244+
});
245+
246+
it('should not fetch when _recachePasswordPolicy is called without existing cache', async () => {
247+
// No prior validation, so no cache exists
248+
await mockAuth._recachePasswordPolicy();
249+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(0);
250+
});
251+
252+
it('should refresh correct tenant cache on invalidation', async () => {
253+
// Cache for tenant-1
254+
mockAuth._tenantId = 'tenant-1';
255+
await mockAuth.validatePassword('Password123$');
256+
257+
// Cache for tenant-2
258+
mockAuth._tenantId = 'tenant-2';
259+
await mockAuth.validatePassword('Password123$');
260+
261+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2);
262+
263+
// Invalidate tenant-1 cache
264+
mockAuth._tenantId = 'tenant-1';
265+
await mockAuth._recachePasswordPolicy();
266+
267+
// Should have fetched again for tenant-1
268+
expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(3);
269+
});
270+
});
271+
});
272+
273+
describe('validatePassword (modular API)', () => {
274+
let validatePassword;
275+
let mockAuth;
276+
277+
beforeEach(async () => {
278+
const modular = await import('../lib/modular/index.js');
279+
validatePassword = modular.validatePassword;
280+
281+
mockAuth = {
282+
app: { name: '[DEFAULT]', options: { apiKey: 'test-api-key' } },
283+
validatePassword: jest.fn(),
284+
};
285+
});
286+
287+
it('should throw error for undefined auth', async () => {
288+
await expect(validatePassword(undefined, 'Password123$')).rejects.toThrow(
289+
"firebase.auth().validatePassword(*) 'auth' must be a valid Auth instance with an 'app' property",
290+
);
291+
});
292+
293+
it('should throw error for auth without app property', async () => {
294+
await expect(validatePassword({}, 'Password123$')).rejects.toThrow(
295+
"firebase.auth().validatePassword(*) 'auth' must be a valid Auth instance with an 'app' property",
296+
);
297+
});
298+
299+
it('should throw error for null password', async () => {
300+
await expect(validatePassword(mockAuth, null)).rejects.toThrow(
301+
"firebase.auth().validatePassword(*) expected 'password' to be a non-null or a defined value.",
302+
);
303+
});
304+
305+
it('should throw error for undefined password', async () => {
306+
await expect(validatePassword(mockAuth, undefined)).rejects.toThrow(
307+
"firebase.auth().validatePassword(*) expected 'password' to be a non-null or a defined value.",
308+
);
309+
});
310+
311+
it('should delegate to auth.validatePassword for valid password', async () => {
312+
mockAuth.validatePassword.mockResolvedValue({ isValid: true });
313+
314+
const result = await validatePassword(mockAuth, 'Password123$');
315+
316+
expect(mockAuth.validatePassword).toHaveBeenCalledWith(
317+
'Password123$',
318+
'react-native-firebase-modular-method-call',
319+
);
320+
expect(result).toEqual({ isValid: true });
321+
});
322+
});

0 commit comments

Comments
 (0)