Skip to content

Commit ad733a5

Browse files
committed
feat(settings): Add account local storage state management components
1 parent 4f0c168 commit ad733a5

11 files changed

Lines changed: 1558 additions & 5 deletions

File tree

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,7 +1689,7 @@ export class AccountHandler {
16891689
// Combine and dedupe bounces by email and createdAt
16901690
const seen = new Set<string>();
16911691
bounces = [...normalizedBounces, ...wildcardBounces].filter(
1692-
(bounce: any) => {
1692+
(bounce: { email: string; createdAt: number }) => {
16931693
const key = `${bounce.email}:${bounce.createdAt}`;
16941694
if (seen.has(key)) return false;
16951695
seen.add(key);
@@ -2364,7 +2364,7 @@ export class AccountHandler {
23642364

23652365
// Format emails
23662366
const formattedEmails = emails.status === 'fulfilled'
2367-
? emails.value.map((email: any) => ({
2367+
? emails.value.map((email: { email: string; isPrimary: boolean; isVerified: boolean }) => ({
23682368
email: email.email,
23692369
isPrimary: email.isPrimary,
23702370
verified: email.isVerified,
@@ -2373,7 +2373,7 @@ export class AccountHandler {
23732373

23742374
// Format linked accounts
23752375
const linkedAccounts = linkedAccountsResult.status === 'fulfilled'
2376-
? linkedAccountsResult.value.map((la: any) => ({
2376+
? linkedAccountsResult.value.map((la: { providerId: number; authAt: number; enabled: boolean }) => ({
23772377
providerId: la.providerId,
23782378
authAt: la.authAt,
23792379
enabled: la.enabled,
@@ -2394,7 +2394,7 @@ export class AccountHandler {
23942394
const devicesCount = devicesResult.status === 'fulfilled' ? devicesResult.value.length : 0;
23952395
const authorizedClients = authorizedClientsResult.status === 'fulfilled' ? authorizedClientsResult.value : [];
23962396
const syncOAuthClientsCount = authorizedClients.filter(
2397-
(client: any) => client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC)
2397+
(client: { scope?: string }) => client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC)
23982398
).length;
23992399
const estimatedSyncDeviceCount = Math.max(devicesCount, syncOAuthClientsCount);
24002400

@@ -2414,7 +2414,7 @@ export class AccountHandler {
24142414

24152415
// Format security events
24162416
const securityEvents = securityEventsResult.status === 'fulfilled'
2417-
? securityEventsResult.value.map((e: any) => ({
2417+
? securityEventsResult.value.map((e: { name: string; createdAt: number; verified?: boolean }) => ({
24182418
name: e.name,
24192419
createdAt: e.createdAt,
24202420
verified: e.verified,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
// jest.mock must precede imports because account-storage.ts calls
6+
// Storage.factory() at module load time — the mock must be in place first.
7+
// eslint-disable-next-line import/first
8+
import {
9+
getAccountData,
10+
updateAccountData,
11+
getCurrentAccountUid,
12+
setCurrentAccountUid,
13+
removeAccount,
14+
clearExtendedAccountState,
15+
isSignedIn,
16+
getSessionVerified,
17+
setSessionVerified,
18+
getFullAccountData,
19+
UnifiedAccountData,
20+
} from './account-storage';
21+
import Storage from './storage';
22+
23+
jest.mock('./storage');
24+
25+
const UID = 'abc123';
26+
const EMAIL = '[email protected]';
27+
const base = {
28+
uid: UID,
29+
email: EMAIL,
30+
verified: true,
31+
metricsEnabled: true,
32+
sessionVerified: true,
33+
sessionToken: 'tok',
34+
};
35+
const store = {
36+
get: jest.fn(),
37+
set: jest.fn(),
38+
remove: jest.fn(),
39+
};
40+
41+
function mockStore(
42+
accounts: Record<string, Partial<UnifiedAccountData>>,
43+
currentUid: string | null = UID
44+
) {
45+
store.get.mockImplementation((key: string) => {
46+
if (key === 'currentAccountUid') return currentUid;
47+
if (key === 'accounts') return accounts;
48+
return null;
49+
});
50+
}
51+
52+
/** Returns the account object from the most recent store.set('accounts', ...) call. */
53+
function savedAccount() {
54+
const calls = store.set.mock.calls.filter((c: [string, unknown]) => c[0] === 'accounts');
55+
return calls[calls.length - 1][1][UID];
56+
}
57+
58+
describe('account-storage', () => {
59+
beforeEach(() => {
60+
jest.clearAllMocks();
61+
(Storage.factory as jest.Mock).mockReturnValue(store);
62+
});
63+
64+
it('getCurrentAccountUid reads from storage', () => {
65+
store.get.mockReturnValue(UID);
66+
expect(getCurrentAccountUid()).toBe(UID);
67+
store.get.mockReturnValue(null);
68+
expect(getCurrentAccountUid()).toBeNull();
69+
});
70+
71+
it('setCurrentAccountUid writes and dispatches event', () => {
72+
const spy = jest.spyOn(window, 'dispatchEvent');
73+
setCurrentAccountUid(UID);
74+
expect(store.set).toHaveBeenCalledWith('currentAccountUid', UID);
75+
expect(spy).toHaveBeenCalledWith(
76+
expect.objectContaining({ type: 'localStorageChange' })
77+
);
78+
spy.mockRestore();
79+
});
80+
81+
describe('getAccountData', () => {
82+
it('returns null when no uid or no account', () => {
83+
store.get.mockReturnValue(null);
84+
expect(getAccountData()).toBeNull();
85+
86+
mockStore({});
87+
expect(getAccountData()).toBeNull();
88+
});
89+
90+
it('fills defaults for missing fields', () => {
91+
mockStore({ [UID]: base });
92+
const result = getAccountData(UID)!;
93+
expect(result.uid).toBe(UID);
94+
expect(result.emails).toEqual([]);
95+
expect(result.totp).toBeNull();
96+
expect(result.hasPassword).toBe(true);
97+
});
98+
99+
it('migrates legacy accountState_{uid} data', () => {
100+
store.get.mockImplementation((key: string) => {
101+
if (key === `accountState_${UID}`) return { displayName: 'Migrated' };
102+
if (key === 'accounts') return { [UID]: base };
103+
return null;
104+
});
105+
getAccountData(UID);
106+
expect(savedAccount().displayName).toBe('Migrated');
107+
expect(store.remove).toHaveBeenCalledWith(`accountState_${UID}`);
108+
});
109+
});
110+
111+
describe('updateAccountData', () => {
112+
it('merges updates and filters transient fields', () => {
113+
mockStore({ [UID]: base });
114+
updateAccountData({
115+
displayName: 'X',
116+
isLoading: true,
117+
loadingFields: ['a'],
118+
error: { message: 'e', name: 'E' },
119+
} as Partial<UnifiedAccountData>);
120+
const saved = savedAccount();
121+
expect(saved.displayName).toBe('X');
122+
expect(saved.isLoading).toBeUndefined();
123+
expect(saved.loadingFields).toBeUndefined();
124+
expect(saved.error).toBeUndefined();
125+
});
126+
127+
it('dispatches localStorageChange event', () => {
128+
const spy = jest.spyOn(window, 'dispatchEvent');
129+
mockStore({ [UID]: base });
130+
updateAccountData({ displayName: 'Y' });
131+
expect(spy).toHaveBeenCalledWith(
132+
expect.objectContaining({ type: 'localStorageChange' })
133+
);
134+
spy.mockRestore();
135+
});
136+
});
137+
138+
it('removeAccount deletes account and clears currentAccountUid', () => {
139+
mockStore({ [UID]: base });
140+
removeAccount(UID);
141+
expect(store.set).toHaveBeenCalledWith('accounts', {});
142+
expect(store.remove).toHaveBeenCalledWith('currentAccountUid');
143+
});
144+
145+
it('clearExtendedAccountState resets extended data, keeps identity', () => {
146+
mockStore({
147+
[UID]: { ...base, displayName: 'Old', totp: { exists: true, verified: true } },
148+
});
149+
clearExtendedAccountState(UID);
150+
const saved = savedAccount();
151+
expect(saved.uid).toBe(UID);
152+
expect(saved.sessionToken).toBe('tok');
153+
expect(saved.displayName).toBeNull();
154+
expect(saved.totp).toBeNull();
155+
});
156+
157+
it('isSignedIn checks sessionToken presence', () => {
158+
mockStore({ [UID]: base });
159+
expect(isSignedIn()).toBe(true);
160+
store.get.mockReturnValue(null);
161+
expect(isSignedIn()).toBe(false);
162+
});
163+
164+
it('getSessionVerified / setSessionVerified read and write', () => {
165+
mockStore({ [UID]: { ...base, sessionVerified: true } });
166+
expect(getSessionVerified(UID)).toBe(true);
167+
168+
mockStore({ [UID]: base });
169+
setSessionVerified(false);
170+
expect(savedAccount().sessionVerified).toBe(false);
171+
});
172+
173+
it('getFullAccountData derives primaryEmail', () => {
174+
const emails = [{ email: EMAIL, isPrimary: true, verified: true }];
175+
mockStore({ [UID]: { ...base, emails } });
176+
expect(getFullAccountData(UID)!.primaryEmail).toEqual(emails[0]);
177+
178+
store.get.mockReturnValue(null);
179+
expect(getFullAccountData()).toBeNull();
180+
});
181+
});

0 commit comments

Comments
 (0)