fix: improve password validation flow (#15132)

Refers to #14984 

Currently, password validation is done backend side and is not explicit
enough so it can be painful to create first users.
We'd like to make this validation easier - but also duplicate it
frontend side to make it smoother.

Flows involved : 
- First user set password
- New user set password
- Change password

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Vincent Vielle
2024-11-05 17:22:32 +01:00
committed by GitHub
co-authored by BrunoQuaresma
parent 8b5a18cade
commit 4fe2c5f09a
21 changed files with 528 additions and 72 deletions
+61
View File
@@ -5373,6 +5373,45 @@ const docTemplate = `{
}
}
},
"/users/validate-password": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Authorization"
],
"summary": "Validate user password",
"operationId": "validate-user-password",
"parameters": [
{
"description": "Validate user password request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.ValidateUserPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ValidateUserPasswordResponse"
}
}
}
}
},
"/users/{user}": {
"get": {
"security": [
@@ -14096,6 +14135,28 @@ const docTemplate = `{
"UserStatusSuspended"
]
},
"codersdk.ValidateUserPasswordRequest": {
"type": "object",
"required": [
"password"
],
"properties": {
"password": {
"type": "string"
}
}
},
"codersdk.ValidateUserPasswordResponse": {
"type": "object",
"properties": {
"details": {
"type": "string"
},
"valid": {
"type": "boolean"
}
}
},
"codersdk.ValidationError": {
"type": "object",
"required": [
+53
View File
@@ -4737,6 +4737,39 @@
}
}
},
"/users/validate-password": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Authorization"],
"summary": "Validate user password",
"operationId": "validate-user-password",
"parameters": [
{
"description": "Validate user password request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.ValidateUserPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ValidateUserPasswordResponse"
}
}
}
}
},
"/users/{user}": {
"get": {
"security": [
@@ -12817,6 +12850,26 @@
"UserStatusSuspended"
]
},
"codersdk.ValidateUserPasswordRequest": {
"type": "object",
"required": ["password"],
"properties": {
"password": {
"type": "string"
}
}
},
"codersdk.ValidateUserPasswordResponse": {
"type": "object",
"properties": {
"details": {
"type": "string"
},
"valid": {
"type": "boolean"
}
}
},
"codersdk.ValidationError": {
"type": "object",
"required": ["detail", "field"],
+1
View File
@@ -1047,6 +1047,7 @@ func New(options *Options) *API {
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
r.Post("/login", api.postLogin)
r.Post("/otp/request", api.postRequestOneTimePasscode)
r.Post("/validate-password", api.validateUserPassword)
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
r.Route("/oauth2", func(r chi.Router) {
r.Route("/github", func(r chi.Router) {
+35
View File
@@ -447,6 +447,41 @@ func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r
}
}
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
//
// @Summary Validate user password
// @ID validate-user-password
// @Security CoderSessionToken
// @Produce json
// @Accept json
// @Tags Authorization
// @Param request body codersdk.ValidateUserPasswordRequest true "Validate user password request"
// @Success 200 {object} codersdk.ValidateUserPasswordResponse
// @Router /users/validate-password [post]
func (*API) validateUserPassword(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
valid = true
details = ""
)
var req codersdk.ValidateUserPasswordRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
err := userpassword.Validate(req.Password)
if err != nil {
valid = false
details = err.Error()
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ValidateUserPasswordResponse{
Valid: valid,
Details: details,
})
}
// Authenticates the user with an email and password.
//
// @Summary Log in user
+94 -38
View File
@@ -5,6 +5,7 @@
package userpassword_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -12,46 +13,101 @@ import (
"github.com/coder/coder/v2/coderd/userpassword"
)
func TestUserPassword(t *testing.T) {
func TestUserPasswordValidate(t *testing.T) {
t.Parallel()
t.Run("Legacy", func(t *testing.T) {
t.Parallel()
// Ensures legacy v1 passwords function for v2.
// This has is manually generated using a print statement from v1 code.
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
require.NoError(t, err)
require.True(t, equal)
})
tests := []struct {
name string
password string
wantErr bool
}{
{name: "Invalid - Too short password", password: "pass", wantErr: true},
{name: "Invalid - Too long password", password: strings.Repeat("a", 65), wantErr: true},
{name: "Invalid - easy password", password: "password", wantErr: true},
{name: "Ok", password: "PasswordSecured123!", wantErr: false},
}
t.Run("Same", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "password")
require.NoError(t, err)
require.True(t, equal)
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := userpassword.Validate(tt.password)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
t.Run("Different", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "notpassword")
require.NoError(t, err)
require.False(t, equal)
})
func TestUserPasswordCompare(t *testing.T) {
t.Parallel()
tests := []struct {
name string
passwordToValidate string
password string
shouldHash bool
wantErr bool
wantEqual bool
}{
{
name: "Legacy",
passwordToValidate: "$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg",
password: "tomato",
shouldHash: false,
wantErr: false,
wantEqual: true,
},
{
name: "Same",
passwordToValidate: "password",
password: "password",
shouldHash: true,
wantErr: false,
wantEqual: true,
},
{
name: "Different",
passwordToValidate: "password",
password: "notpassword",
shouldHash: true,
wantErr: false,
wantEqual: false,
},
{
name: "Invalid",
passwordToValidate: "invalidhash",
password: "password",
shouldHash: false,
wantErr: true,
wantEqual: false,
},
{
name: "InvalidParts",
passwordToValidate: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
password: "test",
shouldHash: false,
wantErr: true,
wantEqual: false,
},
}
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("invalidhash", "password")
require.False(t, equal)
require.Error(t, err)
})
t.Run("InvalidParts", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
require.False(t, equal)
require.Error(t, err)
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.shouldHash {
hash, err := userpassword.Hash(tt.passwordToValidate)
require.NoError(t, err)
tt.passwordToValidate = hash
}
equal, err := userpassword.Compare(tt.passwordToValidate, tt.password)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantEqual, equal)
})
}
}
+18
View File
@@ -1219,6 +1219,24 @@ func TestUpdateUserPassword(t *testing.T) {
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("ValidateUserPassword", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.ValidateUserPassword(ctx, codersdk.ValidateUserPasswordRequest{
Password: "MySecurePassword!",
})
require.NoError(t, err, "users shoud be able to validate complexity of a potential new password")
require.True(t, resp.Valid)
})
t.Run("ChangingPasswordDeletesKeys", func(t *testing.T) {
t.Parallel()
+23
View File
@@ -178,6 +178,15 @@ type UpdateUserProfileRequest struct {
Name string `json:"name" validate:"user_real_name"`
}
type ValidateUserPasswordRequest struct {
Password string `json:"password" validate:"required"`
}
type ValidateUserPasswordResponse struct {
Valid bool `json:"valid"`
Details string `json:"details"`
}
type UpdateUserAppearanceSettingsRequest struct {
ThemePreference string `json:"theme_preference" validate:"required"`
}
@@ -407,6 +416,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req)
if err != nil {
return ValidateUserPasswordResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ValidateUserPasswordResponse{}, ReadBodyAsError(res)
}
var resp ValidateUserPasswordResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserStatus sets the user status to the given status
func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserStatus) (User, error) {
path := fmt.Sprintf("/api/v2/users/%s/status/", user)
+47
View File
@@ -178,6 +178,53 @@ curl -X POST http://coder-server:8080/api/v2/users/otp/request \
| ------ | --------------------------------------------------------------- | ----------- | ------ |
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
## Validate user password
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/users/validate-password \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /users/validate-password`
> Body parameter
```json
{
"password": "string"
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
| `body` | body | [codersdk.ValidateUserPasswordRequest](schemas.md#codersdkvalidateuserpasswordrequest) | true | Validate user password request |
### Example responses
> 200 Response
```json
{
"details": "string",
"valid": true
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ValidateUserPasswordResponse](schemas.md#codersdkvalidateuserpasswordresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Convert user from password to oauth authentication
### Code samples
+30
View File
@@ -6446,6 +6446,36 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `dormant` |
| `suspended` |
## codersdk.ValidateUserPasswordRequest
```json
{
"password": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ------ | -------- | ------------ | ----------- |
| `password` | string | true | | |
## codersdk.ValidateUserPasswordResponse
```json
{
"details": "string",
"valid": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| --------- | ------- | -------- | ------------ | ----------- |
| `details` | string | false | | |
| `valid` | boolean | false | | |
## codersdk.ValidationError
```json
+9
View File
@@ -1322,6 +1322,15 @@ class ApiMethods {
await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword);
};
validateUserPassword = async (
password: string,
): Promise<TypesGen.ValidateUserPasswordResponse> => {
const response = await this.axios.post("/api/v2/users/validate-password", {
password,
});
return response.data;
};
getRoles = async (): Promise<Array<TypesGen.AssignableRoles>> => {
const response = await this.axios.get<TypesGen.AssignableRoles[]>(
"/api/v2/users/roles",
+1
View File
@@ -9,6 +9,7 @@ import type {
UpdateUserProfileRequest,
User,
UsersRequest,
ValidateUserPasswordRequest,
} from "api/typesGenerated";
import {
type MetadataState,
+11
View File
@@ -1779,6 +1779,17 @@ export interface UsersRequest extends Pagination {
readonly q?: string;
}
// From codersdk/users.go
export interface ValidateUserPasswordRequest {
readonly password: string;
}
// From codersdk/users.go
export interface ValidateUserPasswordResponse {
readonly valid: boolean;
readonly details: string;
}
// From codersdk/client.go
export interface ValidationError {
readonly field: string;
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
import { API } from "api/api";
import { useState } from "react";
import { PasswordField } from "./PasswordField";
const meta: Meta<typeof PasswordField> = {
title: "components/PasswordField",
component: PasswordField,
args: {
label: "Password",
},
render: function StatefulPasswordField(args) {
const [value, setValue] = useState("");
return (
<PasswordField
{...args}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof PasswordField>;
export const Idle: Story = {};
const securePassword = "s3curePa$$w0rd";
export const Valid: Story = {
play: async ({ canvasElement }) => {
const validatePasswordSpy = spyOn(
API,
"validateUserPassword",
).mockResolvedValueOnce({ valid: true, details: "" });
const user = userEvent.setup();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Password");
await user.type(input, securePassword);
await waitFor(() =>
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
);
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
},
};
export const Invalid: Story = {
play: async ({ canvasElement }) => {
const validatePasswordSpy = spyOn(
API,
"validateUserPassword",
).mockResolvedValueOnce({
valid: false,
details: "Password is too short.",
});
const user = userEvent.setup();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Password");
await user.type(input, securePassword);
await waitFor(() =>
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
);
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
},
};
@@ -0,0 +1,37 @@
import TextField, { type TextFieldProps } from "@mui/material/TextField";
import { API } from "api/api";
import { useDebouncedValue } from "hooks/debounce";
import type { FC } from "react";
import { useQuery } from "react-query";
// TODO: @BrunoQuaresma: Unable to integrate Yup + Formik for validation. The
// validation was triggering on the onChange event, but the form.errors were not
// updating accordingly. Tried various combinations of validateOnBlur and
// validateOnChange without success. Further investigation is needed.
/**
* A password field component that validates the password against the API with
* debounced calls. It uses a debounced value to minimize the number of API
* calls and displays validation errors.
*/
export const PasswordField: FC<TextFieldProps> = (props) => {
const debouncedValue = useDebouncedValue(`${props.value}`, 500);
const validatePasswordQuery = useQuery({
queryKey: ["validatePassword", debouncedValue],
queryFn: () => API.validateUserPassword(debouncedValue),
keepPreviousData: true,
enabled: debouncedValue.length > 0,
});
const valid = validatePasswordQuery.data?.valid ?? true;
return (
<TextField
{...props}
type="password"
error={!valid || props.error}
helperText={
!valid ? validatePasswordQuery.data?.details : props.helperText
}
/>
);
};
@@ -6,9 +6,10 @@ import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { FormFooter } from "components/FormFooter/FormFooter";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { PasswordField } from "components/PasswordField/PasswordField";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { type FC, useEffect } from "react";
import {
displayNameValidator,
getFormHelpers,
@@ -186,7 +187,7 @@ export const CreateUserForm: FC<
);
})}
</TextField>
<TextField
<PasswordField
{...getFieldHelpers("password", {
helperText:
form.values.login_type !== "password" &&
@@ -198,7 +199,6 @@ export const CreateUserForm: FC<
data-testid="password-input"
disabled={form.values.login_type !== "password"}
label={Language.passwordLabel}
type="password"
/>
</Stack>
<FormFooter
@@ -1,7 +1,8 @@
import { authMethods, createUser } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Margins } from "components/Margins/Margins";
import type { FC } from "react";
import { useDebouncedFunction } from "hooks/debounce";
import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
@@ -49,6 +49,32 @@ describe("Setup Page", () => {
);
});
it("renders the password validation error", async () => {
server.use(
http.post("/api/v2/users/validate-password", () => {
return HttpResponse.json({
valid: false,
details: "Password is too short",
});
}),
);
renderWithRouter(
createMemoryRouter(
[
{
path: "/setup",
element: <SetupPage />,
},
],
{ initialEntries: ["/setup"] },
),
);
await waitForLoaderToBeRemoved();
await fillForm({ password: "short" });
await waitFor(() => screen.findByText("Password is too short"));
});
it("redirects to the app when setup is successful", async () => {
let userHasBeenCreated = false;
@@ -99,6 +125,7 @@ describe("Setup Page", () => {
await fillForm();
await waitFor(() => screen.findByText("Templates"));
});
it("calls sendBeacon with telemetry", async () => {
const sendBeacon = jest.fn();
Object.defineProperty(window.navigator, "sendBeacon", {
+2 -1
View File
@@ -3,7 +3,7 @@ import { createFirstUser } from "api/queries/users";
import { Loader } from "components/Loader/Loader";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useEffect } from "react";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery } from "react-query";
import { Navigate, useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ export const SetupPage: FC = () => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const navigate = useNavigate();
useEffect(() => {
if (!buildInfoQuery.data) {
return;
+3 -4
View File
@@ -10,10 +10,12 @@ import { isAxiosError } from "axios";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { FormFields, VerticalForm } from "components/Form/Form";
import { CoderIcon } from "components/Icons/CoderIcon";
import { PasswordField } from "components/PasswordField/PasswordField";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useEffect } from "react";
import { docs } from "utils/docs";
import {
getFormHelpers,
@@ -33,7 +35,6 @@ export const Language = {
passwordRequired: "Please enter a password.",
create: "Create account",
welcomeMessage: <>Welcome to Coder</>,
firstNameLabel: "First name",
lastNameLabel: "Last name",
companyLabel: "Company",
@@ -167,13 +168,11 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
fullWidth
label={Language.emailLabel}
/>
<TextField
<PasswordField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
/>
<label
htmlFor="trial"
@@ -1,10 +1,13 @@
import LoadingButton from "@mui/lab/LoadingButton";
import TextField from "@mui/material/TextField";
import type * as TypesGen from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Form, FormFields } from "components/Form/Form";
import { PasswordField } from "components/PasswordField/PasswordField";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useEffect } from "react";
import { getFormHelpers } from "utils/formUtils";
import * as Yup from "yup";
@@ -29,11 +32,7 @@ export const Language = {
const validationSchema = Yup.object({
old_password: Yup.string().trim().required(Language.oldPasswordRequired),
password: Yup.string()
.trim()
.min(8, Language.passwordMinLength)
.max(64, Language.passwordMaxLength)
.required(Language.newPasswordRequired),
password: Yup.string().trim().required(Language.newPasswordRequired),
confirm_password: Yup.string()
.trim()
.test("passwords-match", Language.confirmPasswordMatch, function (value) {
@@ -86,12 +85,11 @@ export const SecurityForm: FC<SecurityFormProps> = ({
label={Language.oldPasswordLabel}
type="password"
/>
<TextField
<PasswordField
{...getFieldHelpers("password")}
autoComplete="password"
fullWidth
label={Language.newPasswordLabel}
type="password"
/>
<TextField
{...getFieldHelpers("confirm_password")}
@@ -76,24 +76,6 @@ test("update password with incorrect old password", async () => {
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues);
});
test("update password with invalid password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
);
const { user } = await renderPage();
fillAndSubmitSecurityForm();
const errorMessage = await screen.findAllByText("Invalid password.");
expect(errorMessage).toBeDefined();
expect(errorMessage).toHaveLength(2);
expect(API.updateUserPassword).toBeCalledTimes(1);
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues);
});
test("update password when submit returns an unknown error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",