Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dependencies": {
"@internxt/css-config": "^1.1.0",
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.16.0",
"@internxt/sdk": "^1.16.2",
"@internxt/ui": "^0.1.12",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
Expand Down
46 changes: 34 additions & 12 deletions src/hooks/mail/useMailAccountGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { createTestStore } from '@/test-utils/createTestStore';
import { MailService } from '@/services/sdk/mail';
import { ErrorService } from '@/services/error';
import { MAIL_NOT_SETUP_CODE } from '@/errors';
import { getMockedUser } from '@/test-utils/fixtures';
import { LocalStorageService } from '@/services/local-storage';
import { MailKeysService } from '@/services/mail-keys';
import { openEncryptionKeystore } from 'internxt-crypto';
Expand Down Expand Up @@ -40,7 +39,8 @@ describe('useMailAccountGuard', () => {
MailKeysService.instance.clear();
});

test('When the user has no email yet, then it should stay in loading state', () => {
test('When the keys query is in flight, then it should stay in loading state', () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockReturnValue(new Promise(() => undefined));
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });
Expand All @@ -53,8 +53,7 @@ describe('useMailAccountGuard', () => {
vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic');
const decryptedKeys = { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) };
mockedOpenKeystore.mockResolvedValue(decryptedKeys);
const user = getMockedUser({ email: 'jane@inxt.me' });
const store = createTestStore({ user: { isAuthenticated: true, user } });
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

Expand All @@ -66,8 +65,7 @@ describe('useMailAccountGuard', () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic');
mockedOpenKeystore.mockRejectedValue(new Error('bad keystore'));
const user = getMockedUser({ email: 'jane@inxt.me' });
const store = createTestStore({ user: { isAuthenticated: true, user } });
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

Expand All @@ -79,8 +77,7 @@ describe('useMailAccountGuard', () => {
vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic');
const decryptedKeys = { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) };
mockedOpenKeystore.mockRejectedValueOnce(new Error('bad keystore')).mockResolvedValueOnce(decryptedKeys);
const user = getMockedUser({ email: 'jane@inxt.me' });
const store = createTestStore({ user: { isAuthenticated: true, user } });
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

Expand All @@ -96,22 +93,47 @@ describe('useMailAccountGuard', () => {
status: 403,
code: MAIL_NOT_SETUP_CODE,
} as never);
const user = getMockedUser({ email: 'jane@inxt.me' });
const store = createTestStore({ user: { isAuthenticated: true, user } });
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

await waitFor(() => expect(result.current.status).toBe('not-setup'));
});

test('When decrypted keys are already cached for the address, then it should not call openEncryptionKeystore and be ready', async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
const getMnemonicSpy = vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic');
const cachedKeys = { publicKey: new Uint8Array([9]), secretKey: new Uint8Array([8]) };
MailKeysService.instance.set(mockKeys.address, cachedKeys);
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

await waitFor(() => expect(result.current.status).toBe('ready'));
expect(mockedOpenKeystore).not.toHaveBeenCalled();
expect(getMnemonicSpy).not.toHaveBeenCalled();
expect(MailKeysService.instance.get(mockKeys.address)).toBe(cachedKeys);
});

test('When the mnemonic is missing, then the status should be error and openEncryptionKeystore should not be called', async () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue(null as unknown as string);
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

await waitFor(() => expect(result.current.status).toBe('error'));
expect(mockedOpenKeystore).not.toHaveBeenCalled();
expect(MailKeysService.instance.get(mockKeys.address)).toBeNull();
});

test('When fetching keys fails for another reason, then the status should be error', async () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockRejectedValue(new Error('Network error'));
vi.spyOn(ErrorService.instance, 'castError').mockReturnValue({
message: 'Network error',
status: 500,
} as never);
const user = getMockedUser({ email: 'jane@inxt.me' });
const store = createTestStore({ user: { isAuthenticated: true, user } });
const store = createTestStore();

const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) });

Expand Down
9 changes: 2 additions & 7 deletions src/hooks/mail/useMailAccountGuard.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect, useRef, useState } from 'react';
import { KeystoreType, openEncryptionKeystore } from 'internxt-crypto';
import { useGetMailAccountKeysQuery } from '@/store/api/mail';
import { useAppSelector } from '@/store/hooks';
import { MailNotSetupError } from '@/errors';
import { LocalStorageService } from '@/services/local-storage';
import { MailKeysService } from '@/services/mail-keys';

export type MailAccountGuardStatus = 'loading' | 'ready' | 'not-setup' | 'error';

export const useMailAccountGuard = (): { status: MailAccountGuardStatus } => {
const userEmail = useAppSelector((state) => state.user.user?.email);
const { data, error, isLoading, isFetching } = useGetMailAccountKeysQuery(
userEmail ? { address: userEmail } : skipToken,
);
const { data, error, isLoading, isFetching } = useGetMailAccountKeysQuery();
const lastStartedAddress = useRef<string | null>(null);
const [isDecrypted, setIsDecrypted] = useState(false);
const [decryptError, setDecryptError] = useState(false);
Expand Down Expand Up @@ -66,7 +61,7 @@ export const useMailAccountGuard = (): { status: MailAccountGuardStatus } => {
void decrypt();
}, [address, publicKey, encryptionPrivateKey, decryptError]);

if (!userEmail || isLoading || isFetching) return { status: 'loading' };
if (isLoading || isFetching) return { status: 'loading' };
if (error instanceof MailNotSetupError) return { status: 'not-setup' };
if (error || decryptError) return { status: 'error' };
if (isDecrypted) return { status: 'ready' };
Expand Down
58 changes: 58 additions & 0 deletions src/hooks/mail/useMailKeys.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import type { PropsWithChildren } from 'react';
import { useMailKeys } from './useMailKeys';
import { createTestStore } from '@/test-utils/createTestStore';
import { MailService } from '@/services/sdk/mail';
import { MailKeysService } from '@/services/mail-keys';

const createWrapper = (store: ReturnType<typeof createTestStore>) => {
return ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider>;
};

const mockKeys = {
address: 'jane@inxt.me',
publicKey: 'pub',
encryptionPrivateKey: 'enc',
recoveryPrivateKey: 'rec',
};

describe('useMailKeys', () => {
beforeEach(() => {
vi.restoreAllMocks();
MailKeysService.instance.clear();
});

test('When the keys query has no data yet, then it should return null', () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockReturnValue(new Promise(() => undefined));
const store = createTestStore();

const { result } = renderHook(() => useMailKeys(), { wrapper: createWrapper(store) });

expect(result.current).toBeNull();
});

test('When the address has decrypted keys cached, then it should return them', async () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
const decryptedKeys = { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) };
MailKeysService.instance.set(mockKeys.address, decryptedKeys);
const store = createTestStore();

const { result } = renderHook(() => useMailKeys(), { wrapper: createWrapper(store) });

await waitFor(() => expect(result.current).toBe(decryptedKeys));
});

test('When the address has no cached keys, then it should return null even after data loads', async () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
const store = createTestStore();

const { result, rerender } = renderHook(() => useMailKeys(), { wrapper: createWrapper(store) });

await waitFor(() => {
rerender();
expect(result.current).toBeNull();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
});
9 changes: 5 additions & 4 deletions src/hooks/mail/useMailKeys.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { HybridKeyPair } from 'internxt-crypto';
import { useAppSelector } from '@/store/hooks';
import { useGetMailAccountKeysQuery } from '@/store/api/mail';
import { MailKeysService } from '@/services/mail-keys';

export const useMailKeys = (): HybridKeyPair | null => {
const userEmail = useAppSelector((state) => state.user.user?.email);
if (!userEmail) return null;
return MailKeysService.instance.get(userEmail);
const { data } = useGetMailAccountKeysQuery();
const address = data?.address;
if (!address) return null;
return MailKeysService.instance.get(address);
};
7 changes: 4 additions & 3 deletions src/services/sdk/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ export class MailService {
}

/**
* Gets the mail account keys for the given address.
* Gets the mail account keys. When `address` is omitted the backend returns
* the keys for the caller's default address.
*
* @param address - The mail address whose keys should be retrieved.
* @param address - Optional. The mail address whose keys should be retrieved.
* @returns A promise that resolves with the encrypted keys for the address.
*/
async getMailAccountKeys(address: string): Promise<MailAccountKeysResponse> {
async getMailAccountKeys(address?: string): Promise<MailAccountKeysResponse> {
return this.client.getMailAccountKeys(address);
}

Expand Down
18 changes: 18 additions & 0 deletions src/services/sdk/mail/mail.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ describe('Mail Service', () => {
expect(mockMailClient.getMailAccountKeys).toHaveBeenCalledWith(address);
});

test('When fetching keys without an address, then the client should be called without one', async () => {
const mockKeys = {
address: 'jane@internxt.com',
publicKey: 'pub',
encryptionPrivateKey: 'enc',
recoveryPrivateKey: 'rec',
};
const mockMailClient = {
getMailAccountKeys: vi.fn().mockResolvedValue(mockKeys),
} as any;
vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient);

const result = await MailService.instance.getMailAccountKeys();

expect(result).toStrictEqual(mockKeys);
expect(mockMailClient.getMailAccountKeys).toHaveBeenCalledWith(undefined);
});

test('When fetching keys fails, then an error should be thrown', async () => {
const unexpectedError = new Error('Unexpected error');
const mockMailClient = {
Expand Down
10 changes: 5 additions & 5 deletions src/store/api/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ const patchMailsAfterAction = async ({

export const mailApi = api.injectEndpoints({
endpoints: (builder) => ({
getMailAccountKeys: builder.query<MailAccountKeysResponse, { address: string }>({
async queryFn({
address,
}): Promise<{ data: MailAccountKeysResponse } | { error: MailNotSetupError | FetchMailAccountKeysError }> {
getMailAccountKeys: builder.query<MailAccountKeysResponse, { address?: string } | void>({
async queryFn(
arg,
): Promise<{ data: MailAccountKeysResponse } | { error: MailNotSetupError | FetchMailAccountKeysError }> {
try {
const keys = await MailService.instance.getMailAccountKeys(address);
const keys = await MailService.instance.getMailAccountKeys(arg?.address);
return { data: keys };
} catch (error) {
const err = ErrorService.instance.castError(error);
Expand Down
19 changes: 15 additions & 4 deletions src/store/api/mail/mail.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,12 +467,23 @@ describe('Mail API', () => {
recoveryPrivateKey: 'rec',
};

test('When fetching the mail account keys, then it should return the keys', async () => {
vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
test('When fetching the mail account keys without an address, then it should call the service without one and return the keys', async () => {
const spy = vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate());

expect(spy).toHaveBeenCalledWith(undefined);
expect(result.data).toStrictEqual(mockKeys);
});

test('When fetching the mail account keys with an explicit address, then it should forward the address to the service', async () => {
const spy = vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate({ address }));

expect(spy).toHaveBeenCalledWith(address);
expect(result.data).toStrictEqual(mockKeys);
});

Expand All @@ -486,7 +497,7 @@ describe('Mail API', () => {
} as never);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate({ address }));
const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate());

expect(result.error).toBeInstanceOf(MailNotSetupError);
});
Expand All @@ -500,7 +511,7 @@ describe('Mail API', () => {
} as never);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate({ address }));
const result = await store.dispatch(mailApi.endpoints.getMailAccountKeys.initiate());

expect(result.error).toBeInstanceOf(FetchMailAccountKeysError);
});
Expand Down
Loading