feat(recoveryKey): Add `estimatedSyncDeviceCount` to graphql account resolver

This commit is contained in:
Vijay Budhram 2024-09-05 16:28:51 -04:00
Родитель 58f8e720b2
Коммит c32e74c82f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9778545895B2532B
24 изменённых файлов: 126 добавлений и 76 удалений

Просмотреть файл

@ -75,7 +75,9 @@ describe('#integration - AccountResolver', () => {
useValue: notifierService,
};
authClient = {};
profileClient = {};
profileClient = {
deleteCache: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountResolver,
@ -159,9 +161,10 @@ describe('#integration - AccountResolver', () => {
it('resolves recoveryKey', async () => {
authClient.recoveryKeyExists = jest
.fn()
.mockResolvedValue({ exists: true });
.mockResolvedValue({ exists: true, estimatedSyncDeviceCount: 1 });
const result = await resolver.recoveryKey('token', headers);
expect(result).toBeTruthy();
expect(result.exists).toBeTruthy();
expect(result.estimatedSyncDeviceCount).toBe(1);
});
it('resolves totp', async () => {

Просмотреть файл

@ -37,26 +37,26 @@ import { AuthClientService } from '../backend/auth-client.service';
import { ProfileClientService } from '../backend/profile-client.service';
import { GqlSessionToken, GqlUserId, GqlXHeaders } from '../decorators';
import {
AccountResetInput,
AccountStatusInput,
AttachedClientDisconnectInput,
ChangeRecoveryCodesInput,
CreateTotpInput,
DeleteRecoveryKeyInput,
DeleteTotpInput,
EmailInput,
SendSessionVerificationInput,
UpdateDisplayNameInput,
VerifyEmailInput,
VerifyEmailCodeInput,
VerifySessionInput,
VerifyTotpInput,
PasswordChangeFinishInput,
PasswordChangeStartInput,
PasswordForgotCodeStatusInput,
PasswordForgotSendCodeInput,
PasswordForgotVerifyCodeInput,
PasswordForgotCodeStatusInput,
AccountResetInput,
AccountStatusInput,
RecoveryKeyBundleInput,
PasswordChangeStartInput,
PasswordChangeFinishInput,
SendSessionVerificationInput,
UpdateDisplayNameInput,
VerifyEmailCodeInput,
VerifyEmailInput,
VerifySessionInput,
VerifyTotpInput,
} from './dto/input';
import { DeleteAvatarInput } from './dto/input/delete-avatar';
import { MetricsOptInput } from './dto/input/metrics-opt';
@ -64,21 +64,21 @@ import { RejectUnblockCodeInput } from './dto/input/reject-unblock-code';
import { SignInInput } from './dto/input/sign-in';
import { SignUpInput } from './dto/input/sign-up';
import {
AccountResetPayload,
AccountStatusPayload,
BasicPayload,
ChangeRecoveryCodesPayload,
CreateTotpPayload,
UpdateDisplayNamePayload,
VerifyTotpPayload,
CredentialStatusPayload,
PasswordChangeFinishPayload,
PasswordChangeStartPayload,
PasswordForgotCodeStatusPayload,
PasswordForgotSendCodePayload,
PasswordForgotVerifyCodePayload,
PasswordForgotCodeStatusPayload,
AccountResetPayload,
AccountStatusPayload,
RecoveryKeyBundlePayload,
CredentialStatusPayload,
PasswordChangeStartPayload,
UpdateDisplayNamePayload,
VerifyTotpPayload,
WrappedKeysPayload,
PasswordChangeFinishPayload,
} from './dto/payload';
import { SignedInAccountPayload } from './dto/payload/signed-in-account';
import { SignedUpAccountPayload } from './dto/payload/signed-up-account';
@ -806,12 +806,7 @@ export class AccountResolver {
@GqlSessionToken() token: string,
@GqlXHeaders() headers: Headers
) {
const result = await this.authAPI.recoveryKeyExists(
token,
undefined,
headers
);
return result.exists;
return await this.authAPI.recoveryKeyExists(token, undefined, headers);
}
@ResolveField()

Просмотреть файл

@ -10,6 +10,7 @@ import { Subscription } from './subscription';
import { Totp } from './totp';
import { LinkedAccount } from './linkedAccount';
import { SecurityEvent } from './securityEvent';
import { RecoveryKey } from './recoveryKey';
@ObjectType({
description: "The current authenticated user's Firefox Account record.",
@ -41,10 +42,10 @@ export class Account {
@Field((type) => Totp)
public totp!: Totp;
@Field({
@Field((type) => RecoveryKey, {
description: 'Whether the user has had an account recovery key issued.',
})
public recoveryKey!: boolean;
public recoveryKey!: RecoveryKey;
@Field({ description: 'Whether metrics are enabled and may be reported' })
public metricsEnabled!: boolean;

Просмотреть файл

@ -0,0 +1,19 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class RecoveryKey {
@Field({
nullable: false,
description: 'Whether recovery key exists for user',
})
public exists!: boolean;
@Field({
nullable: true,
description: 'The number of estimated sync devices a user might have.',
})
public estimatedSyncDeviceCount!: number;
}

Просмотреть файл

@ -11,7 +11,10 @@ export const INITIAL_METRICS_QUERY = gql`
query GetInitialMetricsState {
account {
uid
recoveryKey
recoveryKey {
exists
estimatedSyncDeviceCount
}
metricsEnabled
emails {
email

Просмотреть файл

@ -95,7 +95,7 @@ const mockMetricsQueryAccountAmplitude = {
const mockMetricsQueryAccountResult = {
account: {
uid: 'abc123',
recoveryKey: true,
recoveryKey: { exists: true },
metricsEnabled: true,
emails: [
{

Просмотреть файл

@ -202,7 +202,7 @@ export const App = ({
Metrics.init(metricsEnabled, flowQueryParams);
if (data?.account?.metricsEnabled) {
Metrics.initUserPreferences({
recoveryKey: data.account.recoveryKey,
recoveryKey: data.account.recoveryKey.exists,
hasSecondaryVerifiedEmail:
data.account.emails.length > 1 && data.account.emails[1].verified,
totpActive: data.account.totp.exists && data.account.totp.verified,

Просмотреть файл

@ -32,7 +32,9 @@ const accountWithChangeKeySuccess = {
createRecoveryKey: () => {
return new Uint8Array(20);
},
recoveryKey: true,
recoveryKey: {
exists: true,
},
} as unknown as Account;
const accountWithCreateKeySuccess = {
@ -40,7 +42,7 @@ const accountWithCreateKeySuccess = {
createRecoveryKey: () => {
return new Uint8Array(20);
},
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const accountWithInvalidPasswordOnSubmit = {
@ -48,7 +50,7 @@ const accountWithInvalidPasswordOnSubmit = {
createRecoveryKey: () => {
throw AuthUiErrors.INCORRECT_PASSWORD;
},
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const accountWithThrottledErrorOnSubmit = {
@ -56,7 +58,7 @@ const accountWithThrottledErrorOnSubmit = {
createRecoveryKey: () => {
throw AuthUiErrors.THROTTLED;
},
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const StoryWithContext = (account: Account) => {

Просмотреть файл

@ -35,6 +35,7 @@ jest.mock('base32-encode', () =>
const setFormattedRecoveryKey = jest.fn();
const accountWithKeyCreationSuccess = {
recoveryKey: { exists: false },
accountRecovery: false,
createRecoveryKey: jest.fn().mockResolvedValue(new Uint8Array(20)),
} as unknown as Account;
@ -48,6 +49,7 @@ const accountWithKeyChangeSuccess = {
const getAccountWithErrorOnKeyCreation = (error: AuthUiError) => {
return {
recoveryKey: { exists: false },
accountRecovery: false,
createRecoveryKey: () => {
throw error;

Просмотреть файл

@ -49,12 +49,12 @@ export const FlowRecoveryKeyConfirmPwd = ({
const [actionType, setActionType] = useState<RecoveryKeyAction>();
useEffect(() => {
if (account.recoveryKey === true) {
if (account.recoveryKey.exists === true) {
setActionType(RecoveryKeyAction.Change);
} else {
setActionType(RecoveryKeyAction.Create);
}
}, [account.recoveryKey]);
}, [account.recoveryKey.exists]);
const { formState, getValues, handleSubmit, register } = useForm<FormData>({
mode: 'all',

Просмотреть файл

@ -21,7 +21,7 @@ const recoveryKeyRaw = new Uint8Array(20);
const accountWithSuccess = {
...MOCK_ACCOUNT,
recoveryKey: false,
recoveryKey: { exists: false },
createRecoveryKey: () => recoveryKeyRaw,
} as unknown as Account;
@ -48,7 +48,7 @@ const accountWithUnexpectedError = {
const accountWithKeyEnabled = {
...MOCK_ACCOUNT,
recoveryKey: true,
recoveryKey: { exists: true },
createRecoveryKey: () => recoveryKeyRaw,
deleteRecoveryKey: () => true,
} as unknown as Account;

Просмотреть файл

@ -43,13 +43,13 @@ window.URL.createObjectURL = jest.fn();
const accountWithoutKey = {
...MOCK_ACCOUNT,
recoveryKey: false,
recoveryKey: { exists: false },
createRecoveryKey: jest.fn().mockResolvedValue(new Uint8Array(20)),
} as unknown as Account;
const accountWithKey = {
...MOCK_ACCOUNT,
recoveryKey: true,
recoveryKey: { exists: true },
createRecoveryKey: jest.fn().mockResolvedValue(new Uint8Array(20)),
deleteRecoveryKey: jest.fn().mockResolvedValue(true),
} as unknown as Account;

Просмотреть файл

@ -32,7 +32,7 @@ export const PageRecoveryKeyCreate = (props: RouteComponentProps) => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [formattedRecoveryKey, setFormattedRecoveryKey] = useState<string>('');
const action = recoveryKey
const action = recoveryKey.exists
? RecoveryKeyAction.Change
: RecoveryKeyAction.Create;
const goHome = () =>

Просмотреть файл

@ -30,7 +30,7 @@ const coldStartAccount = {
...MOCK_ACCOUNT,
displayName: null,
avatar: { id: null, url: null },
recoveryKey: false,
recoveryKey: { exists: false },
totp: { exists: false, verified: false },
attachedClients: [SERVICES_NON_MOBILE[0]],
} as unknown as Account;

Просмотреть файл

@ -32,7 +32,7 @@ const storyWithAccount = (account: Partial<Account>, storyName?: string) => {
};
export const Default = storyWithAccount({
recoveryKey: false,
recoveryKey: { exists: false },
totp: { exists: false, verified: false },
hasPassword: true,
passwordCreated: 1651860173938,
@ -40,7 +40,7 @@ export const Default = storyWithAccount({
export const SecurityFeaturesEnabled = storyWithAccount(
{
recoveryKey: true,
recoveryKey: { exists: true },
totp: { verified: true, exists: true },
hasPassword: true,
passwordCreated: 1651860173938,
@ -50,7 +50,7 @@ export const SecurityFeaturesEnabled = storyWithAccount(
export const NoPassword = storyWithAccount(
{
recoveryKey: false,
recoveryKey: { exists: false },
totp: { verified: false, exists: false },
hasPassword: false,
},

Просмотреть файл

@ -26,7 +26,9 @@ describe('Security', () => {
displayName: 'Jody',
passwordCreated: 123456789,
hasPassword: true,
recoveryKey: false,
recoveryKey: {
exists: false,
},
totp: { exists: false },
} as unknown as Account;
renderWithRouter(
@ -51,7 +53,7 @@ describe('Security', () => {
emails: [],
displayName: 'Jody',
passwordCreated: 0,
recoveryKey: true,
recoveryKey: { exists: true },
totp: { exists: true, verified: true },
} as unknown as Account;
renderWithRouter(
@ -67,7 +69,7 @@ describe('Security', () => {
describe('Password row', () => {
it('renders as expected when account has a password', async () => {
const account = {
recoveryKey: false,
recoveryKey: { exists: false },
totp: { exists: false },
primaryEmail: {
email: 'jody@mozilla.com',
@ -99,7 +101,7 @@ describe('Security', () => {
it('renders as expected when account does not have a password', async () => {
const account = {
recoveryKey: false,
recoveryKey: { exists: false },
totp: { exists: false },
primaryEmail: {
email: 'jody@mozilla.com',

Просмотреть файл

@ -19,17 +19,17 @@ export default {
const accountHasRecoveryKey = {
hasPassword: true,
recoveryKey: true,
recoveryKey: { exist: true },
} as unknown as Account;
const accountWithoutRecoveryKey = {
hasPassword: true,
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const accountWithoutPassword = {
hasPassword: false,
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const storyWithContext = (

Просмотреть файл

@ -16,17 +16,17 @@ jest.mock('../../../lib/metrics', () => ({
const accountHasRecoveryKey = {
hasPassword: true,
recoveryKey: true,
recoveryKey: { exists: true },
} as unknown as Account;
const accountWithoutRecoveryKey = {
hasPassword: true,
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const accountWithoutPassword = {
hasPassword: false,
recoveryKey: false,
recoveryKey: { exists: false },
} as unknown as Account;
const renderWithContext = (
@ -102,7 +102,7 @@ describe('UnitRowRecoveryKey', () => {
it('emits correct submit and success metrics on successful deletion', async () => {
const accountHasRecoveryKeyWithDeleteSuccess = {
hasPassword: true,
recoveryKey: true,
recoveryKey: { exists: true },
deleteRecoveryKey: jest.fn().mockResolvedValue(true),
} as unknown as Account;
@ -123,7 +123,7 @@ describe('UnitRowRecoveryKey', () => {
it('emits expected submit and failure metrics on failed deletion', async () => {
const accountHasRecoveryKeyWithDeleteFailure = {
hasPassword: true,
recoveryKey: true,
recoveryKey: { exists: true },
deleteRecoveryKey: jest.fn().mockRejectedValue(false),
} as unknown as Account;

Просмотреть файл

@ -17,7 +17,7 @@ import GleanMetrics from '../../../lib/glean';
export const UnitRowRecoveryKey = () => {
const account = useAccount();
const recoveryKey = account.recoveryKey;
const recoveryKey = account.recoveryKey.exists;
const alertBar = useAlertBar();
const [modalRevealed, revealModal, hideModal] = useBooleanState();
const [isLoading, setIsLoading] = useState<boolean>(false);

Просмотреть файл

@ -46,7 +46,7 @@ const mockConfig: Config['glean'] = {
let mockMetricsFlow: MetricsFlow | null = null;
const mockAccount = {
metricsEnabled: true,
recoveryKey: true,
recoveryKey: { exists: true },
totpActive: true,
hasSecondaryVerifiedEmail: false,
} as unknown as ReturnType<typeof useAccount>;

Просмотреть файл

@ -46,7 +46,7 @@ jest.mock('../models', () => ({
// Keep in mind that jest.mock is hoisted, so importing MOCK_ACCOUNT in a
// regular fashion "before" this will not work.
.mockReturnValue({
recoveryKey: true,
recoveryKey: { exists: true },
hasSecondaryVerifiedEmail: false,
totpActive: true,
}),
@ -91,7 +91,7 @@ function initFlow(enabled = true) {
});
initUserPreferences({
hasSecondaryVerifiedEmail: MOCK_ACCOUNT.emails.length > 1,
recoveryKey: MOCK_ACCOUNT.recoveryKey,
recoveryKey: MOCK_ACCOUNT.recoveryKey.exists,
totpActive: MOCK_ACCOUNT.totp.exists && MOCK_ACCOUNT.totp.verified,
});
}

Просмотреть файл

@ -92,7 +92,10 @@ export interface AccountData {
accountCreated: number;
passwordCreated: number;
hasPassword: boolean;
recoveryKey: boolean;
recoveryKey: {
exists: boolean;
estimatedSyncDeviceCount?: number;
};
metricsEnabled: boolean;
primaryEmail: Email;
emails: Email[];
@ -145,7 +148,10 @@ export const GET_ACCOUNT = gql`
}
accountCreated
passwordCreated
recoveryKey
recoveryKey {
exists
estimatedSyncDeviceCount
}
metricsEnabled
primaryEmail @client
emails {
@ -222,7 +228,9 @@ export const GET_CONNECTED_CLIENTS = gql`
export const GET_RECOVERY_KEY_EXISTS = gql`
query GetRecoveryKeyExists {
account {
recoveryKey
recoveryKey {
exists
}
}
}
`;
@ -421,7 +429,7 @@ export class Account implements AccountData {
async hasRecoveryKey(email: string): Promise<boolean> {
// Users may not be logged in (no session token) so we currently can't use GQL here
return this.withLoadingStatus(
(await this.authClient.recoveryKeyExists(sessionToken()!, email)).exists
await this.authClient.recoveryKeyExists(sessionToken()!, email)
);
}
@ -1022,8 +1030,11 @@ export class Account implements AccountData {
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
recoveryKey() {
return false;
recoveryKey(existingData) {
return {
exists: false,
estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount,
};
},
},
});
@ -1186,8 +1197,11 @@ export class Account implements AccountData {
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
recoveryKey() {
return true;
recoveryKey(existingData) {
return {
exists: true,
estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount,
};
},
},
});
@ -1281,8 +1295,11 @@ export class Account implements AccountData {
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
recoveryKey() {
return false;
recoveryKey(existingData) {
return {
exists: false,
estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount,
};
},
},
});

Просмотреть файл

@ -71,7 +71,10 @@ export function defaultAppContext(context?: AppContextValue) {
accountCreated: 123456789,
passwordCreated: 123456789,
hasPassword: true,
recoveryKey: true,
recoveryKey: {
exists: true,
estimatedSyncDeviceCount: 0,
},
metricsEnabled: true,
attachedClients: [],
subscriptions: [],

Просмотреть файл

@ -22,7 +22,10 @@ export const INITIAL_SETTINGS_QUERY = gql`
}
accountCreated
passwordCreated
recoveryKey
recoveryKey {
exists
estimatedSyncDeviceCount
}
metricsEnabled
primaryEmail @client
emails {