feat: upload integration (#651)

- [x] Integrate upload screen with services
- [x] Fixes for wallet backup upload

Co-authored-by: 0xmad <0xmad@users.noreply.github.com>
This commit is contained in:
Anton
2023-07-18 11:40:36 -05:00
committed by GitHub
parent 43c819abf3
commit c46515ef40
9 changed files with 228 additions and 49 deletions

View File

@@ -207,10 +207,10 @@ export default class WalletService implements IBackupable {
return;
}
const rawAuthenticBackup = this.cryptoService.getAuthenticCiphertext(backupEncryptedData, backupPassword);
const authenticBackup = JSON.parse(rawAuthenticBackup) as { accounts: string; mnemonic: string };
const authenticBackup = JSON.parse(backupEncryptedData) as { accounts: string; mnemonic: string };
const accountsAuthenticBackup = this.cryptoService.getAuthenticCiphertext(authenticBackup.accounts, backupPassword);
const newAccounts = JSON.parse(
this.cryptoService.decrypt(authenticBackup.accounts, { secret: backupPassword }),
this.cryptoService.decrypt(accountsAuthenticBackup, { secret: backupPassword }),
) as IAccount[];
const encrypted = await this.accountStorage.get<string>();

View File

@@ -2,8 +2,8 @@ import Box from "@mui/material/Box";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Typography from "@mui/material/Typography";
import { forwardRef, Ref, type HTMLAttributes } from "react";
import type { HTMLAttributes } from "react";
import type { Accept } from "react-dropzone";
import { onDropCallback, useUploadInput } from "./useUploadInput";
@@ -16,14 +16,10 @@ export interface IUploadInputProps extends Omit<HTMLAttributes<HTMLInputElement>
onDrop: onDropCallback;
}
export const UploadInput = ({
isLoading = false,
multiple = true,
errorMessage = "",
accept,
onDrop,
...rest
}: IUploadInputProps): JSX.Element => {
export const UploadInputUI = (
{ isLoading = false, multiple = true, errorMessage = "", accept, onDrop, ...rest }: IUploadInputProps,
ref: Ref<HTMLInputElement>,
): JSX.Element => {
const { isDragActive, acceptedFiles, getRootProps, getInputProps } = useUploadInput({
isLoading,
accept,
@@ -34,7 +30,7 @@ export const UploadInput = ({
const fileTitle = multiple ? "files" : "file";
return (
<Box>
<Box sx={{ width: "100%" }}>
<Box
{...getRootProps({ className: "dropzone" })}
sx={{
@@ -49,9 +45,10 @@ export const UploadInput = ({
borderStyle: "dashed",
outline: "none",
cursor: "pointer",
width: "100%",
}}
>
<input {...rest} {...getInputProps()} />
<input ref={ref} {...rest} {...getInputProps()} />
{isDragActive ? <p>Drop the {fileTitle} here...</p> : <p>Drop some {fileTitle} here, or click to select</p>}
</Box>
@@ -68,3 +65,5 @@ export const UploadInput = ({
</Box>
);
};
export const UploadInput = forwardRef<HTMLInputElement, IUploadInputProps>(UploadInputUI);

View File

@@ -7,7 +7,7 @@ import { RPCAction } from "@cryptkeeperzk/providers";
import { store } from "@src/ui/store/configureAppStore";
import postMessage from "@src/util/postMessage";
import { downloadBackup } from "../backup";
import { downloadBackup, uploadBackup } from "../backup";
jest.mock("@src/util/postMessage");
@@ -25,4 +25,16 @@ describe("ui/ducks/backup", () => {
expect(postMessage).toBeCalledWith({ method: RPCAction.DOWNLOAD_BACKUP, payload: "password" });
expect(result).toBe("content");
});
test("should upload backup properly", async () => {
await Promise.resolve(
store.dispatch(uploadBackup({ password: "password", backupPassword: "password", content: "content" })),
);
expect(postMessage).toBeCalledTimes(1);
expect(postMessage).toBeCalledWith({
method: RPCAction.UPLOAD_BACKUP,
payload: { password: "password", backupPassword: "password", content: "content" },
});
});
});

View File

@@ -2,6 +2,7 @@ import { RPCAction } from "@cryptkeeperzk/providers";
import postMessage from "@src/util/postMessage";
import type { IUploadArgs } from "@src/types";
import type { TypedThunk } from "@src/ui/store/configureAppStore";
export const downloadBackup =
@@ -11,3 +12,15 @@ export const downloadBackup =
method: RPCAction.DOWNLOAD_BACKUP,
payload: password,
});
export const uploadBackup =
({ content, password, backupPassword }: IUploadArgs): TypedThunk<Promise<void>> =>
async () =>
postMessage({
method: RPCAction.UPLOAD_BACKUP,
payload: {
content,
password,
backupPassword,
},
});

View File

@@ -2,7 +2,6 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { Header } from "@src/ui/components/Header";
import { Icon } from "@src/ui/components/Icon";
import { PasswordInput } from "@src/ui/components/PasswordInput";
import { UploadInput } from "@src/ui/components/UploadInput/UploadInput";
@@ -13,10 +12,8 @@ const UploadBackup = (): JSX.Element => {
const { isShowPassword, isLoading, errors, register, onDrop, onGoBack, onShowPassword, onSubmit } = useUploadBackup();
return (
<Box data-testid="upload-backup-page">
<Header />
<Box p={2}>
<Box data-testid="upload-backup-page" sx={{ height: "100%" }}>
<Box sx={{ height: "100%", p: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="h4">Upload backup</Typography>
@@ -25,12 +22,22 @@ const UploadBackup = (): JSX.Element => {
<Box
component="form"
sx={{ mt: 3, height: 430, display: "flex", flexDirection: "column", justifyContent: "space-between" }}
sx={{
mt: 2,
height: "100%",
display: "flex",
flexDirection: "column",
flexWrap: "nowrap",
}}
onSubmit={onSubmit}
>
<Box>
<Typography fontWeight="bold" sx={{ my: 2 }} variant="body1">
To upload your backup, please provide backup file and enter your backup password
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", flexGrow: 1, width: "100%" }}>
<Typography fontWeight="bold" sx={{ mt: 1 }} variant="body1">
To upload your backup, please provide backup file and enter your current and backup password.
</Typography>
<Typography sx={{ my: 1, alignSelf: "flex-start" }} variant="body1">
Note: backup will not override your password and mnemonic phrase
</Typography>
<UploadInput
@@ -42,34 +49,44 @@ const UploadBackup = (): JSX.Element => {
{...register("backupFile", { required: "Backup file is required" })}
/>
<PasswordInput
isShowEye
errorMessage={errors.password}
id="password"
isShowPassword={isShowPassword}
label="Password"
onShowPassword={onShowPassword}
{...register("password", { required: "Password is required" })}
/>
<Box sx={{ width: "100%" }}>
<PasswordInput
isShowEye
errorMessage={errors.password}
id="password"
isShowPassword={isShowPassword}
label="Password"
onShowPassword={onShowPassword}
{...register("password", { required: "Password is required" })}
/>
</Box>
<PasswordInput
errorMessage={errors.backupPassword}
id="backupPassword"
isShowPassword={isShowPassword}
label="Backup password"
onShowPassword={onShowPassword}
{...register("backupPassword", { required: "Backup password is required" })}
/>
<Box sx={{ width: "100%" }}>
<PasswordInput
errorMessage={errors.backupPassword}
id="backupPassword"
isShowPassword={isShowPassword}
label="Backup password"
onShowPassword={onShowPassword}
{...register("backupPassword", { required: "Backup password is required" })}
/>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", mb: 8 }}>
<Button
data-testid="upload-button"
disabled={isLoading}
sx={{ textTransform: "none", mt: 2, width: "100%" }}
sx={{ textTransform: "none", width: "100%" }}
type="submit"
variant="contained"
>
Upload
</Button>
<Typography color="error" sx={{ mt: 1, mx: 1, fontSize: "0.8125rem" }} variant="body2">
{errors.root}
</Typography>
</Box>
</Box>
</Box>

View File

@@ -6,7 +6,12 @@ import { act, renderHook } from "@testing-library/react";
import { useNavigate } from "react-router-dom";
import { mockJsonFile } from "@src/config/mock/file";
import { defaultWalletHookData } from "@src/config/mock/wallet";
import { Paths } from "@src/constants";
import { uploadBackup } from "@src/ui/ducks/backup";
import { useAppDispatch } from "@src/ui/ducks/hooks";
import { useCryptKeeperWallet } from "@src/ui/hooks/wallet";
import { readFile } from "@src/util/file";
import { useUploadBackup } from "../useUploadBackup";
@@ -14,20 +19,40 @@ jest.mock("react-router-dom", (): unknown => ({
useNavigate: jest.fn(),
}));
jest.mock("@src/ui/hooks/wallet", (): unknown => ({
useCryptKeeperWallet: jest.fn(),
}));
jest.mock("@src/ui/ducks/hooks", (): unknown => ({
useAppDispatch: jest.fn(),
}));
jest.mock("@src/ui/ducks/backup", (): unknown => ({
uploadBackup: jest.fn(),
}));
jest.mock("@src/util/file", (): unknown => ({
readFile: jest.fn(),
}));
describe("ui/pages/UploadBackup/useUploadBackup", () => {
const mockNavigate = jest.fn();
const mockDispatch = jest.fn(() => Promise.resolve());
beforeEach(() => {
(readFile as jest.Mock).mockResolvedValue({ target: { result: "{}" } });
(useCryptKeeperWallet as jest.Mock).mockReturnValue(defaultWalletHookData);
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
(useAppDispatch as jest.Mock).mockReturnValue(mockDispatch);
});
afterEach(() => {
jest.clearAllMocks();
});
test("should return initial data", () => {
const { result } = renderHook(() => useUploadBackup());
@@ -37,6 +62,7 @@ describe("ui/pages/UploadBackup/useUploadBackup", () => {
password: undefined,
backupPassword: undefined,
backupFile: undefined,
root: undefined,
});
});
@@ -78,4 +104,58 @@ describe("ui/pages/UploadBackup/useUploadBackup", () => {
expect(result.current.errors.backupFile).toBe("error");
});
test("should submit properly", async () => {
const { result } = renderHook(() => useUploadBackup());
await act(async () =>
Promise.resolve(result.current.register("password").onChange({ target: { value: "password" } })),
);
await act(() =>
Promise.resolve(result.current.register("backupPassword").onChange({ target: { value: "backupPassword" } })),
);
await act(() => Promise.resolve(result.current.onDrop([mockJsonFile], [], new Event("drop"))));
await act(() => Promise.resolve(result.current.onSubmit()));
expect(mockDispatch).toBeCalledTimes(1);
expect(uploadBackup).toBeCalledTimes(1);
expect(defaultWalletHookData.onConnect).toBeCalledTimes(1);
expect(mockNavigate).toBeCalledTimes(1);
expect(mockNavigate).toBeCalledWith(Paths.HOME);
});
test("should handle submit error properly", async () => {
const error = new Error("error");
(mockDispatch as jest.Mock).mockRejectedValue(error);
const { result } = renderHook(() => useUploadBackup());
await act(() => Promise.resolve(result.current.onSubmit()));
expect(result.current.errors.root).toBe(error.message);
});
test("should handle empty file read error properly", async () => {
(readFile as jest.Mock).mockResolvedValue("");
const { result } = renderHook(() => useUploadBackup());
await act(() => Promise.resolve(result.current.onSubmit()));
expect(result.current.errors.root).toBe("Backup file is empty");
});
test("should handle file read error properly", async () => {
const error = new Error("error");
(readFile as jest.Mock).mockRejectedValue(error);
const { result } = renderHook(() => useUploadBackup());
await act(() => Promise.resolve(result.current.onSubmit()));
expect(result.current.errors.root).toBe(error.message);
});
});

View File

@@ -3,22 +3,26 @@ import { FileRejection } from "react-dropzone";
import { UseFormRegister, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Paths } from "@src/constants";
import { uploadBackup } from "@src/ui/ducks/backup";
import { useAppDispatch } from "@src/ui/ducks/hooks";
import { useCryptKeeperWallet } from "@src/ui/hooks/wallet";
import { readFile } from "@src/util/file";
import type { onDropCallback } from "@src/ui/components/UploadInput";
export interface IUseUploadBackupData {
isLoading: boolean;
isShowPassword: boolean;
errors: { password?: string; backupPassword?: string; backupFile?: string };
register: UseFormRegister<UploadBackupFields>;
errors: { password?: string; backupPassword?: string; backupFile?: string; root?: string };
register: UseFormRegister<IUploadBackupFields>;
onDrop: onDropCallback;
onSubmit: (event?: BaseSyntheticEvent) => Promise<void>;
onShowPassword: () => void;
onGoBack: () => void;
}
interface UploadBackupFields {
interface IUploadBackupFields {
password: string;
backupPassword: string;
backupFile: File;
@@ -26,6 +30,7 @@ interface UploadBackupFields {
export const useUploadBackup = (): IUseUploadBackupData => {
const [isShowPassword, setIsShowPassword] = useState(false);
const { onConnect } = useCryptKeeperWallet();
const {
formState: { isLoading, isSubmitting, errors },
@@ -34,7 +39,7 @@ export const useUploadBackup = (): IUseUploadBackupData => {
register,
handleSubmit,
clearErrors,
} = useForm<UploadBackupFields>({
} = useForm<IUploadBackupFields>({
defaultValues: {
password: "",
backupPassword: "",
@@ -63,9 +68,31 @@ export const useUploadBackup = (): IUseUploadBackupData => {
navigate(-1);
}, [navigate]);
const onSubmit = useCallback(() => {
// TODO: implement
}, [dispatch, onGoBack, setError]);
const onSubmit = useCallback(
async (data: IUploadBackupFields) => {
const content = await readFile(data.backupFile)
.then((res) => {
const text = res.target?.result;
if (!text) {
setError("root", { message: "Backup file is empty" });
}
return text?.toString();
})
.catch((error: Error) => setError("root", { message: error.message }));
if (!content) {
return;
}
dispatch(uploadBackup({ password: data.password, backupPassword: data.backupPassword, content }))
.then(() => onConnect())
.then(() => navigate(Paths.HOME))
.catch((error: Error) => setError("root", { message: error.message }));
},
[dispatch, navigate, setError, onConnect],
);
const onShowPassword = useCallback(() => {
setIsShowPassword((isShow) => !isShow);
@@ -78,6 +105,7 @@ export const useUploadBackup = (): IUseUploadBackupData => {
password: errors.password?.message,
backupPassword: errors.backupPassword?.message,
backupFile: errors.backupFile?.message,
root: errors.root?.message,
},
register,
onDrop,

View File

@@ -0,0 +1,21 @@
/**
* @jest-environment jsdom
*/
import { mockJsonFile } from "@src/config/mock/file";
import { readFile } from "../file";
describe("util/file", () => {
test("should read file properly", async () => {
const result = await readFile(mockJsonFile);
expect(result.target?.result).toBe(JSON.stringify({ ping: true }));
});
test("should return empty string is there is no read result", async () => {
const result = await readFile(new File([], "name"));
expect(result.target?.result).toBe("");
});
});

View File

@@ -0,0 +1,9 @@
export const readFile = async (file: Blob): Promise<ProgressEvent<FileReader>> =>
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.addEventListener("load", resolve);
fileReader.addEventListener("error", reject);
fileReader.readAsText(file);
});