diff --git a/packages/apps/human-app/frontend/package.json b/packages/apps/human-app/frontend/package.json index 737c1349c2..69da3e2772 100644 --- a/packages/apps/human-app/frontend/package.json +++ b/packages/apps/human-app/frontend/package.json @@ -8,7 +8,9 @@ "start:prod": "serve -s dist", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", - "prepare": "husky" + "prepare": "husky", + "test": "vitest --run", + "test:watch": "vitest" }, "lint-staged": { "*.{ts,tsx}": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" @@ -16,9 +18,10 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "@faker-js/faker": "^9.7.0", "@fontsource/inter": "^5.0.17", "@hcaptcha/react-hcaptcha": "^0.3.6", - "@hookform/resolvers": "^3.3.4", + "@hookform/resolvers": "^5.0.1", "@human-protocol/sdk": "*", "@mui/icons-material": "^7.0.1", "@mui/material": "^5.16.7", @@ -38,7 +41,7 @@ "query-string": "^9.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.53.2", + "react-hook-form": "^7.55.0", "react-i18next": "^15.1.0", "react-imask": "^7.4.0", "react-number-format": "^5.4.3", @@ -70,6 +73,7 @@ "lint-staged": "^15.4.3", "prettier": "^3.4.2", "typescript": "^5.6.3", - "vite": "^6.2.4" + "vite": "^6.2.4", + "vitest": "^3.1.1" } } diff --git a/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx b/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx index f4e11c0811..cd2170544b 100644 --- a/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx @@ -27,7 +27,7 @@ export function SignInForm({ }: Readonly) { const { t } = useTranslation(); - const methods = useForm({ + const methods = useForm({ defaultValues: { email: '', password: '', diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-existing-keys-form.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-existing-keys-form.tsx index 1ce6275f3e..7ef7b929f3 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-existing-keys-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-existing-keys-form.tsx @@ -7,7 +7,7 @@ import { Input } from '@/shared/components/data-entry/input'; import type { EthKVStoreKeyValues } from '@/modules/smart-contracts/EthKVStore/config'; import { EthKVStoreKeys, - Role, + OPERATOR_ROLES, } from '@/modules/smart-contracts/EthKVStore/config'; import { Select } from '@/shared/components/data-entry/select'; import { MultiSelect } from '@/shared/components/data-entry/multi-select'; @@ -17,12 +17,6 @@ import { useColorMode } from '@/shared/contexts/color-mode'; import { PercentsInputMask } from '@/shared/components/data-entry/input-masks'; import { sortFormKeys, STORE_KEYS_ORDER } from '../../utils'; -const OPTIONS = [ - Role.EXCHANGE_ORACLE, - Role.JOB_LAUNCHER, - Role.RECORDING_ORACLE, -]; - const formInputsConfig: Record = { [EthKVStoreKeys.Fee]: ( = { isChipRenderValue label={t('operator.addKeysPage.existingKeys.role')} name={EthKVStoreKeys.Role} - options={OPTIONS.map((role, i) => ({ + options={OPERATOR_ROLES.map((role, i) => ({ name: role, value: role, id: i, diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-pending-keys-form.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-pending-keys-form.tsx index d837a860af..47b318bf22 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-pending-keys-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/edit-pending-keys-form.tsx @@ -4,7 +4,7 @@ import { Input } from '@/shared/components/data-entry/input'; import type { EthKVStoreKeyValues } from '@/modules/smart-contracts/EthKVStore/config'; import { EthKVStoreKeys, - Role, + OPERATOR_ROLES, } from '@/modules/smart-contracts/EthKVStore/config'; import { Select } from '@/shared/components/data-entry/select'; import { MultiSelect } from '@/shared/components/data-entry/multi-select'; @@ -13,12 +13,6 @@ import type { GetEthKVStoreValuesSuccessResponse } from '@/modules/operator/hook import { PercentsInputMask } from '@/shared/components/data-entry/input-masks'; import { sortFormKeys, STORE_KEYS_ORDER } from '../../utils'; -const OPTIONS = [ - Role.EXCHANGE_ORACLE, - Role.JOB_LAUNCHER, - Role.RECORDING_ORACLE, -]; - const formInputsConfig: Record = { [EthKVStoreKeys.Fee]: ( = { isChipRenderValue label={t('operator.addKeysPage.existingKeys.role')} name={EthKVStoreKeys.Role} - options={OPTIONS.map((role, i) => ({ + options={OPERATOR_ROLES.map((role, i) => ({ name: role, value: role, id: i, diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/existing-keys-form.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/existing-keys-form.tsx index f393a97be5..d7b0687727 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/existing-keys-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/existing-keys-form.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { Grid } from '@mui/material'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { UseFormReturn } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form'; import type { GetEthKVStoreValuesSuccessResponse } from '@/modules/operator/hooks/use-get-keys'; import { useResetMutationErrors } from '@/shared/hooks/use-reset-mutation-errors'; @@ -13,11 +12,6 @@ import { import { EditExistingKeysForm } from './edit-existing-keys-form'; import { ExistingKeys } from './existing-keys'; -export type UseFormResult = UseFormReturn< - GetEthKVStoreValuesSuccessResponse, - EditEthKVStoreValuesMutationData ->; - export function ExistingKeysForm({ keysData, }: Readonly<{ @@ -25,13 +19,7 @@ export function ExistingKeysForm({ }>) { const [editMode, setEditMode] = useState(false); const existingKeysMutation = useEditExistingKeysMutation(); - const pendingKeysMutation = useEditExistingKeysMutation(); - const existingKeysMethods = useForm< - GetEthKVStoreValuesSuccessResponse, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- automatic inferring - any, - EditEthKVStoreValuesMutationData - >({ + const existingKeysMethods = useForm({ defaultValues: keysData, resolver: zodResolver(getEditEthKVStoreValuesMutationSchema(keysData)), }); @@ -52,14 +40,7 @@ export function ExistingKeysForm({ gap: '3rem', }} > - - {...existingKeysMethods} - > +
{ void existingKeysMethods.handleSubmit(handleEditExistingKeys)( @@ -74,7 +55,7 @@ export function ExistingKeysForm({ loading: existingKeysMutation.isPending, type: 'submit', variant: 'contained', - disabled: pendingKeysMutation.isPending, + disabled: existingKeysMutation.isPending, }} /> ) : ( diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/pending-keys-form.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/pending-keys-form.tsx index 75bb5e1d2a..db1376256c 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/pending-keys-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-keys/pending-keys-form.tsx @@ -18,12 +18,7 @@ export function PendingKeysForm({ }>) { const pendingKeysMutation = useEditExistingKeysMutation(); - const pendingKeysMethods = useForm< - GetEthKVStoreValuesSuccessResponse, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- automatic inferring - any, - EditEthKVStoreValuesMutationData - >({ + const pendingKeysMethods = useForm({ defaultValues: {}, resolver: zodResolver(setEthKVStoreValuesMutationSchema(keysData)), }); @@ -35,14 +30,7 @@ export function PendingKeysForm({ useResetMutationErrors(pendingKeysMethods.watch, pendingKeysMutation.reset); return ( - - {...pendingKeysMethods} - > + { void pendingKeysMethods.handleSubmit(handleEditPendingKey)(event); diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-stake/stake-form.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-stake/stake-form.tsx index 22ba036ff1..c19476e112 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-stake/stake-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/components/add-stake/stake-form.tsx @@ -26,7 +26,7 @@ export function StakeForm({ }>) { const addStakeMutation = useAddStake(); - const methods = useForm({ + const methods = useForm({ defaultValues: { // Since we deal with numbers that may have huge decimal extensions, // we are using strings as a safer solution diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/schema/eth-kv-store-values-mutation-schema.ts b/packages/apps/human-app/frontend/src/modules/signup/operator/schema/eth-kv-store-values-mutation-schema.ts index 836d3216e7..3c7e9ecd47 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/schema/eth-kv-store-values-mutation-schema.ts +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/schema/eth-kv-store-values-mutation-schema.ts @@ -3,16 +3,16 @@ import { t } from 'i18next'; import { EthKVStoreKeys, JobType, - Role, + OPERATOR_ROLES, } from '@/modules/smart-contracts/EthKVStore/config'; -import type { GetEthKVStoreValuesSuccessResponse } from '@/modules/operator/hooks/use-get-keys'; +import { type GetEthKVStoreValuesSuccessResponse } from '@/modules/operator/hooks/use-get-keys'; import { urlDomainSchema } from '@/shared/schemas'; const fieldsValidations = { [EthKVStoreKeys.PublicKey]: urlDomainSchema, [EthKVStoreKeys.Url]: urlDomainSchema, [EthKVStoreKeys.WebhookUrl]: urlDomainSchema, - [EthKVStoreKeys.Role]: z.nativeEnum(Role), + [EthKVStoreKeys.Role]: z.enum(OPERATOR_ROLES), [EthKVStoreKeys.JobTypes]: z.array(z.nativeEnum(JobType)).min(1), [EthKVStoreKeys.Fee]: z.coerce // eslint-disable-next-line camelcase @@ -62,43 +62,51 @@ export const setEthKVStoreValuesMutationSchema = ( export const getEditEthKVStoreValuesMutationSchema = ( initialData: GetEthKVStoreValuesSuccessResponse ) => { - return editEthKVStoreValuesMutationSchema.transform((newData, ctx) => { - const fieldsThatHasChanges: EditEthKVStoreValuesMutationData = {}; - Object.values(EthKVStoreKeys).forEach((key) => { - const newFiledData = newData[key]; - const initialFiledData = initialData[key]; + return editEthKVStoreValuesMutationSchema.transform( + (newData, ctx) => { + const fieldsThatHasChanges: EditEthKVStoreValuesMutationData = {}; + Object.values(EthKVStoreKeys).forEach((key) => { + const newFiledData = newData[key]; + const initialFiledData = initialData[key]; - if (Array.isArray(newFiledData) && Array.isArray(initialFiledData)) { - if ( - newFiledData.sort().toString() === initialFiledData.sort().toString() + let hasFieldChanged = false; + if (Array.isArray(newFiledData) && Array.isArray(initialFiledData)) { + if ( + newFiledData.sort().toString() !== + initialFiledData.sort().toString() + ) { + hasFieldChanged = true; + } + } else if ( + typeof newFiledData === 'number' && + newFiledData.toString() !== initialFiledData?.toString() ) { - return; + hasFieldChanged = true; + } else { + // eslint-disable-next-line eqeqeq -- expect to do conversion for this compare + hasFieldChanged = newFiledData != initialFiledData; } - Object.assign(fieldsThatHasChanges, { [key]: newFiledData.toString() }); - return; - } - if ( - typeof newFiledData === 'number' && - newFiledData.toString() !== initialFiledData?.toString() - ) { - Object.assign(fieldsThatHasChanges, { [key]: newFiledData }); - return; - } + if (hasFieldChanged) { + Object.assign(fieldsThatHasChanges, { [key]: newFiledData }); + } + }); - // eslint-disable-next-line eqeqeq -- expect to do conversion for this compare - if (newFiledData != initialFiledData) { - Object.assign(fieldsThatHasChanges, { [key]: newFiledData }); + if (Object.values(fieldsThatHasChanges).length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('operator.addKeysPage.editKeysForm.error'), + path: ['form'], + }); + + return z.NEVER; } - }); - if (!Object.values(fieldsThatHasChanges).length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t('operator.addKeysPage.editKeysForm.error'), - path: ['form'], - }); + return fieldsThatHasChanges; } - return fieldsThatHasChanges; - }); + ) as z.ZodType< + EditEthKVStoreValuesMutationData, + z.ZodTypeDef, + GetEthKVStoreValuesSuccessResponse + >; }; diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/utils/__tests__/staked-amount-formatter.test.ts b/packages/apps/human-app/frontend/src/modules/signup/operator/utils/__tests__/staked-amount-formatter.test.ts new file mode 100644 index 0000000000..fcbd9da66d --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/utils/__tests__/staked-amount-formatter.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { stakedAmountFormatter } from '../staked-amount-formatter'; +import '@/shared/i18n/i18n'; + +describe('stakedAmountFormatter Function', () => { + it('should format amounts with no decimal part correctly', () => { + const amount = BigInt('1000000000000000000'); + + const result = stakedAmountFormatter(amount); + expect(result).toBe('1 HMT'); + }); + + it('should format amounts with decimal part correctly', () => { + const amount = BigInt('1500000000000000000'); + + const result = stakedAmountFormatter(amount); + expect(result).toBe('1.5 HMT'); + }); + + it('should handle very small amounts', () => { + const amount = BigInt('1'); + + const result = stakedAmountFormatter(amount); + expect(result).toBe('0.000000000000000001 HMT'); + }); + + it('should handle zero amount', () => { + const amount = BigInt('0'); + + const result = stakedAmountFormatter(amount); + expect(result).toBe('0 HMT'); + }); + + it('should handle large amounts', () => { + const amount = BigInt('1000000000000000000000'); + + const result = stakedAmountFormatter(amount); + expect(result).toBe('1000 HMT'); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-keys.page.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-keys.page.tsx index 64b60237c5..bee5b1bfe1 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-keys.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-keys.page.tsx @@ -1,4 +1,3 @@ -import type { UseFormReturn } from 'react-hook-form'; import { t } from 'i18next'; import { PageCardError, @@ -7,19 +6,10 @@ import { } from '@/shared/components/ui/page-card'; import { getErrorMessageForError, jsonRpcErrorHandler } from '@/shared/errors'; import { Alert } from '@/shared/components/ui/alert'; -import { - type GetEthKVStoreValuesSuccessResponse, - useGetKeys, -} from '@/modules/operator/hooks'; +import { useGetKeys } from '@/modules/operator/hooks'; import { useEditExistingKeysMutationState } from '../hooks'; -import { type EditEthKVStoreValuesMutationData } from '../schema'; import { AddKeysForm } from '../components/add-keys'; -export type UseFormResult = UseFormReturn< - GetEthKVStoreValuesSuccessResponse, - EditEthKVStoreValuesMutationData ->; - export function AddKeysOperatorPage() { const { data: keysData, diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-stake.page.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-stake.page.tsx index c70abdf43b..0a32dc50f7 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-stake.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/views/add-stake.page.tsx @@ -71,11 +71,7 @@ export function AddStakeOperatorPage() { ); } - if ( - isGetStakedAmountPending || - isDecimalsDataPending || - decimalsData === undefined - ) { + if (isGetStakedAmountPending || isDecimalsDataPending) { return ; } diff --git a/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx b/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx index 06f9696137..af647203b7 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx @@ -15,12 +15,12 @@ import { HCaptchaForm } from '@/shared/components/hcaptcha'; import { useResetMutationErrors } from '@/shared/hooks/use-reset-mutation-errors'; import { FetchError } from '@/api/fetcher'; import { useSignUpWorker } from '@/modules/signup/worker/hooks/use-sign-up-worker'; -import { signUpDtoSchema, type SignUpDto } from '../schema'; +import { signUpDtoSchema } from '../schema'; export function SignUpWorkerPage() { const { t } = useTranslation(); const { signUp, error, isError, isLoading, reset } = useSignUpWorker(); - const methods = useForm({ + const methods = useForm({ defaultValues: { email: '', password: '', @@ -48,7 +48,7 @@ export function SignUpWorkerPage() { alert={ isError ? ( - {getErrorMessageForError(isError, handleSignupError)} + {getErrorMessageForError(error, handleSignupError)} ) : undefined } diff --git a/packages/apps/human-app/frontend/src/modules/smart-contracts/EthKVStore/config.ts b/packages/apps/human-app/frontend/src/modules/smart-contracts/EthKVStore/config.ts index 6c7f8e74e0..6d7774132f 100644 --- a/packages/apps/human-app/frontend/src/modules/smart-contracts/EthKVStore/config.ts +++ b/packages/apps/human-app/frontend/src/modules/smart-contracts/EthKVStore/config.ts @@ -1,9 +1,10 @@ -export enum Role { - JOB_LAUNCHER = 'Job Launcher', - EXCHANGE_ORACLE = 'Exchange Oracle', - REPUTATION_ORACLE = 'Reputation Oracle', - RECORDING_ORACLE = 'Recording Oracle', -} +import { Role } from '@human-protocol/sdk/src/constants'; + +export const OPERATOR_ROLES = [ + Role.ExchangeOracle, + Role.JobLauncher, + Role.RecordingOracle, +] as const; export enum JobType { FORTUNE = 'fortune', diff --git a/packages/apps/human-app/frontend/src/modules/smart-contracts/check-network.spec.ts b/packages/apps/human-app/frontend/src/modules/smart-contracts/check-network.spec.ts new file mode 100644 index 0000000000..c630e472b7 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/smart-contracts/check-network.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Network } from 'ethers'; +import { t } from 'i18next'; +import { faker } from '@faker-js/faker'; +import { env } from '@/shared/env'; +import { MainnetChains, TestnetChains } from '@/modules/smart-contracts/chains'; +import { checkNetwork } from './check-network'; +import '@/shared/i18n/i18n'; + +vi.mock('@/shared/env', () => ({ + env: { + VITE_NETWORK: 'testnet', + }, +})); + +describe('checkNetwork Function', () => { + it('should not throw an error for supported testnet network', () => { + const network: Network = { + chainId: BigInt(TestnetChains[0].chainId), + name: 'Test Network', + } as Network; + + expect(() => { + checkNetwork(network); + }).not.toThrow(); + }); + + it('should throw an error for unsupported testnet network', () => { + const networkName = faker.string.alpha(10); + + const network: Network = { + chainId: BigInt(9999), + name: networkName, + } as Network; + + expect(() => { + checkNetwork(network); + }).toThrow(t('errors.unsupportedNetworkWithName', { networkName })); + }); + + it('should not throw an error for supported mainnet network', () => { + vi.mocked(env).VITE_NETWORK = 'mainnet'; + + const network: Network = { + chainId: BigInt(MainnetChains[0].chainId), + name: 'Main Network', + } as Network; + + expect(() => { + checkNetwork(network); + }).not.toThrow(); + }); + + it('should throw an error for unsupported mainnet network', () => { + vi.mocked(env).VITE_NETWORK = 'mainnet'; + const networkName = faker.string.alpha(10); + + const network: Network = { + chainId: BigInt(9999), + name: networkName, + } as Network; + + expect(() => { + checkNetwork(network); + }).toThrow(t('errors.unsupportedNetworkWithName', { networkName })); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/smart-contracts/get-network-name.spec.ts b/packages/apps/human-app/frontend/src/modules/smart-contracts/get-network-name.spec.ts new file mode 100644 index 0000000000..5d6704006e --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/smart-contracts/get-network-name.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { env } from '@/shared/env'; +import { + AllTestnetsChains, + AllMainnetChains, +} from '@/modules/smart-contracts/chains'; +import { getNetworkName } from './get-network-name'; + +vi.mock('@/shared/env', () => ({ + env: { + VITE_NETWORK: 'testnet', + }, +})); + +describe('getNetworkName Function', () => { + it('should return the correct testnet chain name when VITE_NETWORK is testnet', () => { + const chainId = AllTestnetsChains[0].chainId; + const expectedName = AllTestnetsChains[0].name; + + const result = getNetworkName(chainId); + expect(result).toBe(expectedName); + }); + + it('should return an empty string for non-existent testnet chain ID', () => { + const nonExistentChainId = 999999; + + const result = getNetworkName(nonExistentChainId); + expect(result).toBe(''); + }); + + it('should return the correct mainnet chain name when VITE_NETWORK is not testnet', () => { + vi.mocked(env).VITE_NETWORK = 'mainnet'; + + const chainId = AllMainnetChains[0].chainId; + const expectedName = AllMainnetChains[0].name; + + const result = getNetworkName(chainId); + expect(result).toBe(expectedName); + }); + + it('should return an empty string for non-existent mainnet chain ID', () => { + vi.mocked(env).VITE_NETWORK = 'mainnet'; + + const nonExistentChainId = 999999; + + const result = getNetworkName(nonExistentChainId); + expect(result).toBe(''); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/resend-verification-email-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/resend-verification-email-form.tsx index ef740aff1a..76b22ebd60 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/resend-verification-email-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/resend-verification-email-form.tsx @@ -1,4 +1,5 @@ import { FormProvider } from 'react-hook-form'; +import { type z } from 'zod'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import { Trans, useTranslation } from 'react-i18next'; @@ -7,13 +8,18 @@ import { Button } from '@/shared/components/ui/button'; import { HCaptchaForm } from '@/shared/components/hcaptcha/h-captcha-form'; import { MailTo } from '@/shared/components/ui/mail-to'; import { env } from '@/shared/env'; -import { type ResendEmailVerificationDto } from '../schemas'; +import { + type resendEmailVerificationHcaptchaSchema, + type ResendEmailVerificationDto, +} from '../schemas'; interface ResendVerificationEmailFormProps { - methods: UseFormReturn>; - handleResend: ( - data: Pick - ) => void; + methods: UseFormReturn< + z.input, + unknown, + ResendEmailVerificationDto + >; + handleResend: (data: ResendEmailVerificationDto) => void; email: string; isAuthenticated: boolean; } diff --git a/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts b/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts index efb764e5a6..b8d0648153 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts @@ -16,7 +16,7 @@ export function useResendEmail() { mutate: resendEmailVerificationMutation, reset: resendEmailVerificationMutationReset, } = useResendEmailVerificationWorkerMutation(); - const methods = useForm>({ + const methods = useForm({ defaultValues: { h_captcha_token: '', }, @@ -25,9 +25,7 @@ export function useResendEmail() { useResetMutationErrors(methods.watch, resendEmailVerificationMutationReset); - const handleResend = ( - data: Pick - ) => { + const handleResend = (data: ResendEmailVerificationDto) => { resendEmailVerificationMutation({ h_captcha_token: data.h_captcha_token, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx index 2906bc2129..abb5fe312c 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx @@ -41,10 +41,7 @@ export function OraclesTable() { if (isOraclesDataError) { return ( ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.spec.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.spec.ts new file mode 100644 index 0000000000..117e9daaf9 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { env } from '@/shared/env'; +import { isHCaptchaOracle } from './is-hcaptcha-oracle'; + +vi.mock('@/shared/env', () => ({ + env: { + VITE_H_CAPTCHA_ORACLE_ADDRESS: '0x1234567890abcdef1234567890abcdef12345678', + }, +})); + +describe('isHCaptchaOracle Helper', () => { + it('should return true when the address matches the hCaptcha oracle address', () => { + const result = isHCaptchaOracle( + '0x1234567890abcdef1234567890abcdef12345678' + ); + expect(result).toBe(true); + }); + + it('should return false when the address does not match the hCaptcha oracle address', () => { + const result = isHCaptchaOracle(faker.finance.ethereumAddress()); + expect(result).toBe(false); + }); + + it('should handle case sensitivity correctly', () => { + const mixedCaseAddress = env.VITE_H_CAPTCHA_ORACLE_ADDRESS.replace( + 'abcdef', + 'ABCDEF' + ); + + const result = isHCaptchaOracle(mixedCaseAddress); + expect(result).toBe(true); + }); + + it('should handle empty strings', () => { + const result = isHCaptchaOracle(''); + expect(result).toBe(false); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.ts index c9c3903a60..9b5cefc8a3 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/is-hcaptcha-oracle.ts @@ -1,4 +1,4 @@ import { env } from '@/shared/env'; export const isHCaptchaOracle = (address: string): boolean => - address === env.VITE_H_CAPTCHA_ORACLE_ADDRESS; + address.toLowerCase() === env.VITE_H_CAPTCHA_ORACLE_ADDRESS.toLowerCase(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.spec.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.spec.ts new file mode 100644 index 0000000000..b13736a403 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { type Oracle } from '../../hooks'; +import { shouldNavigateToRegistration } from './should-navigate-to-registration'; + +describe('shouldNavigateToRegistration Helper', () => { + const oracle: Oracle = { + address: faker.finance.ethereumAddress(), + registrationNeeded: true, + chainId: faker.number.int({ min: 1, max: 100 }), + role: faker.word.noun(), + url: faker.internet.url(), + jobTypes: [faker.word.noun()], + name: faker.company.name(), + }; + + const oracleWithNoRegistration: Oracle = { + ...oracle, + registrationNeeded: false, + }; + + it('should return true when registration is needed and oracle address is not in the array', () => { + const registrationData = { + // eslint-disable-next-line camelcase + oracle_addresses: [faker.finance.ethereumAddress()], + }; + + const result = shouldNavigateToRegistration(oracle, registrationData); + expect(result).toBe(true); + }); + + it('should return false when registration is needed but oracle address is in the array', () => { + const registrationData = { + // eslint-disable-next-line camelcase + oracle_addresses: [oracle.address], + }; + + const result = shouldNavigateToRegistration(oracle, registrationData); + expect(result).toBe(false); + }); + + it('should return false when registration is not needed, regardless of address', () => { + const registrationData = { + // eslint-disable-next-line camelcase + oracle_addresses: [], + }; + + const result = shouldNavigateToRegistration( + oracleWithNoRegistration, + registrationData + ); + expect(result).toBe(false); + }); + + it('should return false when registration is not needed and registrationData is undefined', () => { + const result = shouldNavigateToRegistration(oracleWithNoRegistration); + expect(result).toBe(false); + }); + + it('should return true when registration is needed and registrationData is undefined', () => { + const result = shouldNavigateToRegistration(oracle); + expect(result).toBe(true); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts index 92be181be3..94ef5a5805 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts @@ -25,7 +25,7 @@ function assignJob(data: AssignJobBody) { export function useAssignJobMutation( callbacks?: { onSuccess: () => void; - onError: (error: unknown) => void; + onError: (error: Error) => void; }, mutationKey?: MutationKey ) { diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx index a1443e3ec0..6d288d9be9 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx @@ -27,7 +27,7 @@ export function EscrowAddressSearchForm({ }: SearchFormProps) { const isMobile = useIsMobile(); const { colorPalette } = useColorMode(); - const methods = useForm<{ searchValue: string }>({ + const methods = useForm({ defaultValues: { searchValue: '', }, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx index 3dede4418f..474a8294f8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx @@ -16,7 +16,7 @@ export const useJobsNotifications = () => { }); }; - const onJobAssignmentError = (error: unknown) => { + const onJobAssignmentError = (error: Error) => { showNotification({ message: getErrorMessageForError(error), type: TopNotificationType.WARNING, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/__tests__/get-chip-status-color.test.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/__tests__/get-chip-status-color.test.ts new file mode 100644 index 0000000000..7c524483e0 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/__tests__/get-chip-status-color.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { colorPalette } from '@/shared/styles/color-palette'; +import { getChipStatusColor } from '../get-chip-status-color'; +import { MyJobStatus, UNKNOWN_JOB_STATUS } from '../../../types'; + +describe('getChipStatusColor Function', () => { + it('should return the secondary main color for ACTIVE status', () => { + const result = getChipStatusColor(MyJobStatus.ACTIVE, colorPalette); + expect(result).toBe(colorPalette.secondary.main); + }); + + it('should return the success main color for COMPLETED status', () => { + const result = getChipStatusColor(MyJobStatus.COMPLETED, colorPalette); + expect(result).toBe(colorPalette.success.main); + }); + + it('should return the error light color for VALIDATION status', () => { + const result = getChipStatusColor(MyJobStatus.VALIDATION, colorPalette); + expect(result).toBe(colorPalette.error.light); + }); + + it('should return the error main color for unknown status', () => { + const result = getChipStatusColor(UNKNOWN_JOB_STATUS, colorPalette); + expect(result).toBe(colorPalette.error.main); + }); +}); diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx index e78e075f73..43715d9d94 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx @@ -30,7 +30,7 @@ export function RegistrationForm({ error, } = useExchangeOracleRegistrationMutation(); - const methods = useForm({ + const methods = useForm({ defaultValues: { h_captcha_token: '', }, diff --git a/packages/apps/human-app/frontend/src/modules/worker/reset-password/reset-password.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/reset-password/reset-password.page.tsx index a652add11d..6aa16103a5 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/reset-password/reset-password.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/reset-password/reset-password.page.tsx @@ -25,7 +25,7 @@ export function ResetPasswordWorkerPage() { const location = useLocation(); const { token } = queryString.parse(location.search); - const methods = useForm({ + const methods = useForm({ defaultValues: { password: '', confirmPassword: '', diff --git a/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link-success.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link-success.page.tsx index bb959449f3..d46590d57a 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link-success.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link-success.page.tsx @@ -35,7 +35,7 @@ export function SendResetLinkWorkerSuccessPage() { mutate({ ...dto, email: email ?? '' }); }; - const methods = useForm({ + const methods = useForm({ defaultValues: { h_captcha_token: '', }, diff --git a/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link.page.tsx index 1af8aab18b..2ad53e0729 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/send-reset-link/send-reset-link.page.tsx @@ -19,7 +19,7 @@ export function SendResetLinkWorkerPage() { const { t } = useTranslation(); const { user } = useAuth(); - const methods = useForm({ + const methods = useForm({ defaultValues: { email: user?.email ?? '', h_captcha_token: '', diff --git a/packages/apps/human-app/frontend/src/shared/errors/get-error-message-for-error.ts b/packages/apps/human-app/frontend/src/shared/errors/get-error-message-for-error.ts index c56d0e5cd2..3abe649d8d 100644 --- a/packages/apps/human-app/frontend/src/shared/errors/get-error-message-for-error.ts +++ b/packages/apps/human-app/frontend/src/shared/errors/get-error-message-for-error.ts @@ -5,7 +5,7 @@ import { JsonRpcError } from '@/modules/smart-contracts/json-rpc-error'; type CustomErrorHandler = (unknownError: unknown) => string | undefined; export function getErrorMessageForError( - unknownError: unknown, + unknownError: Error | null, customErrorHandler?: CustomErrorHandler ): string { let customError: string | undefined; diff --git a/packages/apps/human-app/frontend/src/shared/helpers/date.spec.ts b/packages/apps/human-app/frontend/src/shared/helpers/date.spec.ts new file mode 100644 index 0000000000..0c2c68639c --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/helpers/date.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { formatDate, parseDate, getTomorrowDate } from './date'; + +describe('Date Helper Functions', () => { + describe('formatDate', () => { + it('should correctly format a date string', () => { + const dateString = faker.date.anytime().toISOString(); + const formattedDate = formatDate(dateString); + expect(formattedDate).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + }); + + describe('parseDate', () => { + it('should correctly parse milliseconds into days, hours, minutes, and seconds', () => { + // Test case: 1 day, 2 hours, 30 minutes, 15 seconds + const milliseconds = + 1 * 24 * 60 * 60 * 1000 + // 1 day + 2 * 60 * 60 * 1000 + // 2 hours + 30 * 60 * 1000 + // 30 minutes + 15 * 1000; // 15 seconds + + const result = parseDate(milliseconds); + + expect(result.days).toBe(1); + expect(result.hours).toBe(2); + expect(result.minutes).toBe(30); + expect(result.seconds).toBe(15); + }); + + it('should handle zero values correctly', () => { + const result = parseDate(0); + + expect(result.days).toBe(0); + expect(result.hours).toBe(0); + expect(result.minutes).toBe(0); + expect(result.seconds).toBe(0); + }); + + it('should throw on negative values', () => { + const negativeMilliseconds = -1; + expect(() => parseDate(negativeMilliseconds)).toThrow( + 'Negative values are not allowed' + ); + }); + }); + + describe('getTomorrowDate', () => { + it('should return a date object for tomorrow', () => { + const today = new Date(); + const tomorrow = getTomorrowDate(); + + expect(tomorrow).toBeInstanceOf(Date); + + const tomorrowExpected = new Date(today); + tomorrowExpected.setDate(tomorrowExpected.getDate() + 1); + + expect(tomorrow.getFullYear()).toBe(tomorrowExpected.getFullYear()); + expect(tomorrow.getMonth()).toBe(tomorrowExpected.getMonth()); + expect(tomorrow.getDate()).toBe(tomorrowExpected.getDate()); + + expect(tomorrow.getUTCHours()).toBe(7); + expect(tomorrow.getUTCMinutes()).toBe(0); + expect(tomorrow.getUTCSeconds()).toBe(0); + expect(tomorrow.getUTCMilliseconds()).toBe(0); + }); + }); +}); diff --git a/packages/apps/human-app/frontend/src/shared/helpers/date.ts b/packages/apps/human-app/frontend/src/shared/helpers/date.ts index 1c9b79d423..1cf0efd329 100644 --- a/packages/apps/human-app/frontend/src/shared/helpers/date.ts +++ b/packages/apps/human-app/frontend/src/shared/helpers/date.ts @@ -1,7 +1,7 @@ import { parseISO, format } from 'date-fns'; -export const formatDate = (dateString: string) => { - const parsedDate = parseISO(dateString); +export const formatDate = (isoDateString: string) => { + const parsedDate = parseISO(isoDateString); return format(parsedDate, 'yyyy-MM-dd HH:mm:ss'); }; @@ -13,6 +13,10 @@ export interface ParsedDate { } export function parseDate(delta: number): ParsedDate { + if (delta < 0) { + throw new Error('Negative values are not allowed'); + } + const days = Math.floor(delta / (1000 * 60 * 60 * 24)); const hours = Math.floor((delta % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((delta % (1000 * 60 * 60)) / (1000 * 60)); diff --git a/packages/apps/human-app/frontend/src/shared/helpers/evm.spec.ts b/packages/apps/human-app/frontend/src/shared/helpers/evm.spec.ts new file mode 100644 index 0000000000..caddd3c577 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/helpers/evm.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { shortenEscrowAddress } from './evm'; + +describe('EVM Helper Functions', () => { + describe('shortenEscrowAddress', () => { + it('should shorten a long Ethereum address with default padding', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const shortened = shortenEscrowAddress(address); + expect(shortened).toBe('0x12345...45678'); + }); + + it('should shorten a long Ethereum address with custom padding', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const shortened = shortenEscrowAddress(address, 5, 3); + expect(shortened).toBe('0x123...678'); + }); + + it('should not shorten an address that is too short', () => { + const address = '0x12345'; + const shortened = shortenEscrowAddress(address); + expect(shortened).toBe(address); + }); + + it('should handle empty strings', () => { + const address = ''; + const shortened = shortenEscrowAddress(address); + expect(shortened).toBe(address); + }); + }); +}); diff --git a/packages/apps/human-app/frontend/src/shared/helpers/string.spec.ts b/packages/apps/human-app/frontend/src/shared/helpers/string.spec.ts new file mode 100644 index 0000000000..5c8a9da687 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/helpers/string.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { padZero } from './string'; + +describe('String Helper Functions', () => { + describe('padZero', () => { + it('should pad zero', () => { + const paddedNumber = padZero(0); + expect(paddedNumber).toBe(`00`); + }); + + it('should pad single digit numbers with a leading zero', () => { + const number = faker.number.int({ min: 0, max: 9 }); + const paddedNumber = padZero(number); + expect(paddedNumber).toBe(`0${number}`); + }); + + it('should not pad double digit numbers', () => { + const number = faker.number.int({ min: 11, max: 999 }); + const paddedNumber = padZero(number); + expect(paddedNumber).toBe(`${number}`); + }); + + it('should handle negative numbers according to implementation', () => { + const number = faker.number.int({ min: -100000, max: -1 }); + const paddedNumber = padZero(number); + expect(paddedNumber).toBe(`${number}`); + }); + }); +}); diff --git a/packages/apps/human-app/frontend/src/shared/helpers/string.ts b/packages/apps/human-app/frontend/src/shared/helpers/string.ts index 90418afebd..c581c6b754 100644 --- a/packages/apps/human-app/frontend/src/shared/helpers/string.ts +++ b/packages/apps/human-app/frontend/src/shared/helpers/string.ts @@ -1,3 +1,3 @@ export function padZero(num: number): string { - return num < 10 ? `0${num.toString()}` : num.toString(); + return num >= 0 && num < 10 ? `0${num.toString()}` : num.toString(); } diff --git a/packages/apps/human-app/frontend/src/shared/i18n/en.json b/packages/apps/human-app/frontend/src/shared/i18n/en.json index 70ee9859b2..019b21646b 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -1,7 +1,7 @@ { "humanCurrencySymbol": "HMT", "inputMasks": { - "humanCurrencySuffix": " HMT", + "humanCurrencySuffix": "HMT", "percentSuffix": "%", "plusPrefix": "+" }, diff --git a/packages/apps/human-app/frontend/src/shared/types/global.type.ts b/packages/apps/human-app/frontend/src/shared/types/global.type.ts index dd8e51b82b..56f0a676aa 100644 --- a/packages/apps/human-app/frontend/src/shared/types/global.type.ts +++ b/packages/apps/human-app/frontend/src/shared/types/global.type.ts @@ -6,7 +6,7 @@ export interface Children { children?: React.ReactNode; } -export type ResponseError = FetchError | Error | ZodError | JsonRpcError | null; +export type ResponseError = FetchError | Error | ZodError | JsonRpcError; declare module '@tanstack/react-query' { interface Register { diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 0044cf15a0..d75fea0237 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -44,6 +44,8 @@ import { HealthModule } from './modules/health/health.module'; import { UiConfigurationModule } from './modules/ui-configuration/ui-configuration.module'; import { NDAModule } from './modules/nda/nda.module'; import { NDAController } from './modules/nda/nda.controller'; +import { AbuseController } from './modules/abuse/abuse.controller'; +import { AbuseModule } from './modules/abuse/abuse.module'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -126,6 +128,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); HealthModule, UiConfigurationModule, NDAModule, + AbuseModule, ], controllers: [ AppController, @@ -139,6 +142,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); RegisterAddressController, TokenRefreshController, NDAController, + AbuseController, ], exports: [HttpModule], providers: [EnvironmentConfigService], diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index ff3518bc88..f499dbb562 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -125,6 +125,16 @@ export class GatewayConfigService { method: HttpMethod.POST, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.REPORT_ABUSE]: { + endpoint: '/abuse/report', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.GET_ABUSE_REPORTS]: { + endpoint: '/abuse/reports', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { diff --git a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts index 149368a3f2..1bd52fba79 100644 --- a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts +++ b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts @@ -17,8 +17,10 @@ export enum ReputationOracleEndpoints { KYC_ON_CHAIN = 'kyc_on_chain', REGISTRATION_IN_EXCHANGE_ORACLE = 'registration_in_exchange_oracle', GET_REGISTRATION_IN_EXCHANGE_ORACLES = 'get_registration_in_exchange_oracles', - GET_LATEST_NDA = 'GET_LATEST_NDA', - SIGN_NDA = 'SIGN_NDA', + GET_LATEST_NDA = 'get_latest_nda', + SIGN_NDA = 'sign_nda', + REPORT_ABUSE = 'report_abuse', + GET_ABUSE_REPORTS = 'get_abuse_reports', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index 6819b220e6..2cd64d719c 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -88,6 +88,11 @@ import { SignNDAData, SignNDAResponse, } from '../../modules/nda/model/nda.model'; +import { + ReportAbuseCommand, + ReportAbuseData, + ReportedAbuseResponse, +} from '../../modules/abuse/model/abuse.model'; @Injectable() export class ReputationOracleGateway { @@ -395,4 +400,23 @@ export class ReputationOracleGateway { options, ); } + + async sendAbuseReport(command: ReportAbuseCommand) { + const data = this.mapper.map(command, ReportAbuseCommand, ReportAbuseData); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.REPORT_ABUSE, + data, + command.token, + ); + return this.handleRequestToReputationOracle(options); + } + + async getAbuseReports(token: string): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.GET_ABUSE_REPORTS, + undefined, + token, + ); + return this.handleRequestToReputationOracle(options); + } } diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts index 6021483d8c..842416bc19 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts @@ -58,6 +58,10 @@ import { SigninOperatorData, } from '../../modules/user-operator/model/operator-signin.model'; import { SignNDACommand, SignNDAData } from '../../modules/nda/model/nda.model'; +import { + ReportAbuseCommand, + ReportAbuseData, +} from '../../modules/abuse/model/abuse.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -153,6 +157,15 @@ export class ReputationOracleProfile extends AutomapperProfile { destination: new SnakeCaseNamingConvention(), }), ); + createMap( + mapper, + ReportAbuseCommand, + ReportAbuseData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/modules/abuse/abuse.controller.ts b/packages/apps/human-app/server/src/modules/abuse/abuse.controller.ts new file mode 100644 index 0000000000..a2f336a3f6 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/abuse.controller.ts @@ -0,0 +1,70 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { + Body, + Controller, + Get, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Authorization } from '../../common/config/params-decorators'; +import { AbuseService } from './abuse.service'; +import { + ReportedAbuseResponse, + ReportAbuseCommand, + ReportAbuseDto, +} from './model/abuse.model'; + +@ApiBearerAuth() +@ApiTags('Abuse') +@Controller('/abuse') +export class AbuseController { + constructor( + private readonly service: AbuseService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @Post('/report') + @ApiOperation({ + summary: 'Report an identified abuse', + }) + @ApiResponse({ + status: 200, + description: 'Abuse report successfully submitted', + }) + @UsePipes(new ValidationPipe()) + public async reportAbuse( + @Body() AbuseDto: ReportAbuseDto, + @Authorization() token: string, + ): Promise { + const AbuseCommand = this.mapper.map( + AbuseDto, + ReportAbuseDto, + ReportAbuseCommand, + ); + AbuseCommand.token = token; + return this.service.reportAbuse(AbuseCommand); + } + + @Get('/reports') + @ApiOperation({ + summary: 'Retrieve all abuse entities created by the authenticated user', + }) + @ApiResponse({ + status: 200, + description: 'List of abuse reports', + type: ReportedAbuseResponse, + }) + public async getUserAbuseReports( + @Authorization() token: string, + ): Promise { + return this.service.getUserAbuseReports(token); + } +} diff --git a/packages/apps/human-app/server/src/modules/abuse/abuse.mapper.profile.ts b/packages/apps/human-app/server/src/modules/abuse/abuse.mapper.profile.ts new file mode 100644 index 0000000000..d7c7a4f096 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/abuse.mapper.profile.ts @@ -0,0 +1,50 @@ +import { + CamelCaseNamingConvention, + createMap, + forMember, + Mapper, + mapWith, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + ReportAbuseCommand, + ReportAbuseDto, + ReportAbuseParams, +} from './model/abuse.model'; + +@Injectable() +export class AbuseProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + ReportAbuseDto, + ReportAbuseParams, + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + ReportAbuseDto, + ReportAbuseCommand, + forMember( + (destination) => destination.data, + mapWith(ReportAbuseParams, ReportAbuseDto, (source) => source), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/abuse/abuse.module.ts b/packages/apps/human-app/server/src/modules/abuse/abuse.module.ts new file mode 100644 index 0000000000..9677532a56 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/abuse.module.ts @@ -0,0 +1,11 @@ +import { AbuseService } from './abuse.service'; +import { AbuseProfile } from './abuse.mapper.profile'; +import { Module } from '@nestjs/common'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; + +@Module({ + imports: [ReputationOracleModule], + providers: [AbuseService, AbuseProfile], + exports: [AbuseService], +}) +export class AbuseModule {} diff --git a/packages/apps/human-app/server/src/modules/abuse/abuse.service.ts b/packages/apps/human-app/server/src/modules/abuse/abuse.service.ts new file mode 100644 index 0000000000..cd555b4d17 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/abuse.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { ReportAbuseCommand } from './model/abuse.model'; + +@Injectable() +export class AbuseService { + constructor(private gateway: ReputationOracleGateway) {} + + async reportAbuse(command: ReportAbuseCommand): Promise { + return this.gateway.sendAbuseReport(command); + } + + async getUserAbuseReports(token: string) { + return this.gateway.getAbuseReports(token); + } +} diff --git a/packages/apps/human-app/server/src/modules/abuse/model/abuse.model.ts b/packages/apps/human-app/server/src/modules/abuse/model/abuse.model.ts new file mode 100644 index 0000000000..37eec9942a --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/model/abuse.model.ts @@ -0,0 +1,47 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEthereumAddress, IsNumber } from 'class-validator'; + +export class ReportAbuseDto { + @AutoMap() + @IsEthereumAddress() + @ApiProperty() + escrow_address: string; + @AutoMap() + @IsNumber() + @Type(() => Number) + @ApiProperty() + chain_id: number; +} + +export class ReportAbuseParams { + @AutoMap() + chainId: number; + @AutoMap() + escrowAddress: string; +} +export class ReportAbuseCommand { + @AutoMap() + data: ReportAbuseParams; + @AutoMap() + token: string; +} + +export class ReportAbuseData { + @AutoMap() + escrow_address: string; + @AutoMap() + chain_id: number; +} + +export class ReportedAbuseItem { + id: number; + escrowAddress: string; + chainId: number; + status: string; +} + +export class ReportedAbuseResponse { + results: ReportedAbuseItem[]; +} diff --git a/packages/apps/human-app/server/src/modules/abuse/spec/abuse.controller.spec.ts b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.controller.spec.ts new file mode 100644 index 0000000000..29ced5ac79 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.controller.spec.ts @@ -0,0 +1,65 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; + +import { AbuseController } from '../abuse.controller'; +import { AbuseService } from '../abuse.service'; +import { abuseServiceMock } from './abuse.service.mock'; +import { + reportAbuseCommandFixture, + reportAbuseDtoFixture, + reportedAbuseResponseFixture, + TOKEN, +} from './abuse.fixtures'; +import { AbuseProfile } from '../abuse.mapper.profile'; + +describe('AbuseController', () => { + let controller: AbuseController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AbuseController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [AbuseService, AbuseProfile], + }) + .overrideProvider(AbuseService) + .useValue(abuseServiceMock) + .compile(); + + controller = module.get(AbuseController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('reportAbuse', () => { + it('should call service reportAbuse method with proper fields set', async () => { + const dto = reportAbuseDtoFixture; + const command = reportAbuseCommandFixture; + + await controller.reportAbuse(dto, TOKEN); + + expect(abuseServiceMock.reportAbuse).toHaveBeenCalledWith(command); + }); + }); + + describe('getUserAbuseReports', () => { + it('should call service getUserAbuseReports method with the token', async () => { + const token = TOKEN; + + abuseServiceMock.getUserAbuseReports.mockResolvedValueOnce( + reportedAbuseResponseFixture, + ); + + const result = await controller.getUserAbuseReports(token); + + expect(abuseServiceMock.getUserAbuseReports).toHaveBeenCalledWith(token); + expect(result).toEqual(reportedAbuseResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/abuse/spec/abuse.fixtures.ts b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.fixtures.ts new file mode 100644 index 0000000000..f0a0fe6439 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.fixtures.ts @@ -0,0 +1,39 @@ +import { + ReportAbuseDto, + ReportAbuseParams, + ReportAbuseCommand, + ReportedAbuseResponse, + ReportedAbuseItem, +} from '../model/abuse.model'; + +const ESCROW_ADDRESS = 'test_address'; +const CHAIN_ID = 1; +const STATUS = 'reported'; +const ABUSE_ID = 1; +export const TOKEN = 'test_user_token'; + +export const reportAbuseDtoFixture: ReportAbuseDto = { + chain_id: CHAIN_ID, + escrow_address: ESCROW_ADDRESS, +}; + +export const reportAbuseParamsFixture: ReportAbuseParams = { + chainId: CHAIN_ID, + escrowAddress: ESCROW_ADDRESS, +}; + +export const reportAbuseCommandFixture: ReportAbuseCommand = { + data: reportAbuseParamsFixture, + token: TOKEN, +}; + +export const reportedAbuseItemFixture: ReportedAbuseItem = { + id: ABUSE_ID, + escrowAddress: ESCROW_ADDRESS, + chainId: CHAIN_ID, + status: STATUS, +}; + +export const reportedAbuseResponseFixture: ReportedAbuseResponse = { + results: [reportedAbuseItemFixture], +}; diff --git a/packages/apps/human-app/server/src/modules/abuse/spec/abuse.service.mock.ts b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.service.mock.ts new file mode 100644 index 0000000000..e900b55c44 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/abuse/spec/abuse.service.mock.ts @@ -0,0 +1,4 @@ +export const abuseServiceMock = { + reportAbuse: jest.fn(), + getUserAbuseReports: jest.fn(), +}; diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index 0ffe4427b5..9c49d7a92f 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -77,6 +77,10 @@ SYNAPS_WEBHOOK_SECRET= SYNAPS_BASE_URL= SYNAPS_STEP_DOCUMENT_ID= +# Slack notifications +ABUSE_SLACK_WEBHOOK_URL= +ABUSE_SLACK_OAUTH_TOKEN= +ABUSE_SLACK_SIGNING_SECRET= # NDA NDA_URL= diff --git a/packages/apps/reputation-oracle/server/package.json b/packages/apps/reputation-oracle/server/package.json index e9589dc0f1..c22acc085a 100644 --- a/packages/apps/reputation-oracle/server/package.json +++ b/packages/apps/reputation-oracle/server/package.json @@ -44,6 +44,9 @@ "@nestjs/terminus": "^11.0.0", "@nestjs/typeorm": "^10.0.1", "@sendgrid/mail": "^8.1.3", + "@slack/bolt": "^4.2.1", + "@slack/web-api": "^7.9.1", + "@slack/webhook": "^7.0.5", "@types/passport-jwt": "^4.0.1", "axios": "^1.8.1", "bcrypt": "^5.1.1", @@ -65,6 +68,7 @@ "rxjs": "^7.2.0", "typeorm": "^0.3.16", "typeorm-naming-strategies": "^4.1.0", + "uuid": "^11.1.0", "zxcvbn": "^4.4.2" }, "devDependencies": { @@ -87,6 +91,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "jest": "29.7.0", + "nock": "^14.0.3", "pino-pretty": "^13.0.0", "prettier": "^3.4.2", "source-map-support": "^0.5.20", diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index 1c13e15e96..ebd52542e9 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -17,22 +17,19 @@ import { HttpValidationPipe } from './common/pipes'; import { HealthModule } from './modules/health/health.module'; import { ReputationModule } from './modules/reputation/reputation.module'; -import { Web3Module } from './modules/web3/web3.module'; import { AuthModule } from './modules/auth/auth.module'; import { KycModule } from './modules/kyc/kyc.module'; import { CronJobModule } from './modules/cron-job/cron-job.module'; -import { PayoutModule } from './modules/payout/payout.module'; import { QualificationModule } from './modules/qualification/qualification.module'; import { EscrowCompletionModule } from './modules/escrow-completion/escrow-completion.module'; import { WebhookIncomingModule } from './modules/webhook/webhook-incoming.module'; import { WebhookOutgoingModule } from './modules/webhook/webhook-outgoing.module'; import { UserModule } from './modules/user'; -import { EmailModule } from './modules/email/module'; import { NDAModule } from './modules/nda/nda.module'; -import { StorageModule } from './modules/storage/storage.module'; import Environment from './utils/environment'; import { AppController } from './app.controller'; +import { AbuseModule } from './modules/abuse/abuse.module'; @Module({ providers: [ @@ -82,20 +79,17 @@ import { AppController } from './app.controller'; }), EnvConfigModule, DatabaseModule, + AbuseModule, AuthModule, CronJobModule, - EmailModule, UserModule, NDAModule, EscrowCompletionModule, HealthModule, KycModule, - PayoutModule, QualificationModule, ReputationModule, - StorageModule, UserModule, - Web3Module, WebhookIncomingModule, WebhookOutgoingModule, ], diff --git a/packages/apps/reputation-oracle/server/src/common/enums/cron-job.ts b/packages/apps/reputation-oracle/server/src/common/enums/cron-job.ts index 59568e48b9..43b19067f2 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/cron-job.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/cron-job.ts @@ -4,4 +4,6 @@ export enum CronJobType { ProcessPendingEscrowCompletionTracking = 'process-pending-escrow-completion-tracking', ProcessPaidEscrowCompletionTracking = 'process-paid-escrow-completion-tracking', ProcessAwaitingEscrowPayouts = 'process-awaiting-escrow-payouts', + ProcessRequestedAbuse = 'process-requested-abuse', + ProcessClassifiedAbuse = 'process-classified-abuse', } diff --git a/packages/apps/reputation-oracle/server/src/common/enums/http.ts b/packages/apps/reputation-oracle/server/src/common/enums/http.ts new file mode 100644 index 0000000000..38b1444115 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/common/enums/http.ts @@ -0,0 +1,5 @@ +export enum ContentType { + JSON = 'application/json', + PLAIN_TEXT = 'text/plain', + BINARY = 'application/octet-stream', +} diff --git a/packages/apps/reputation-oracle/server/src/common/enums/index.ts b/packages/apps/reputation-oracle/server/src/common/enums/index.ts index e542fa9df9..f2453fe149 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/index.ts @@ -3,3 +3,4 @@ export * from './reputation'; export * from './webhook'; export * from './collection'; export * from './hcaptcha'; +export * from './http'; diff --git a/packages/apps/reputation-oracle/server/src/common/enums/webhook.ts b/packages/apps/reputation-oracle/server/src/common/enums/webhook.ts index 8843979f7a..a00f84eb3e 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/webhook.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/webhook.ts @@ -1,6 +1,8 @@ export enum EventType { JOB_COMPLETED = 'job_completed', ESCROW_COMPLETED = 'escrow_completed', + ABUSE_DETECTED = 'abuse_detected', + ABUSE_DISMISSED = 'abuse_dismissed', } export enum WebhookIncomingStatus { diff --git a/packages/apps/reputation-oracle/server/src/common/errors/database.ts b/packages/apps/reputation-oracle/server/src/common/errors/database.ts index d7e4a0701e..653a8285c4 100644 --- a/packages/apps/reputation-oracle/server/src/common/errors/database.ts +++ b/packages/apps/reputation-oracle/server/src/common/errors/database.ts @@ -9,7 +9,8 @@ export function handleQueryFailedError(error: QueryFailedError): DatabaseError { switch ((error.driverError as any).code) { case PostgresErrorCodes.Duplicated: - message = (error.driverError as any).detail; + message = + (error.driverError as any).detail + (error.driverError as any).code; break; case PostgresErrorCodes.NumericFieldOverflow: message = 'Incorrect amount'; diff --git a/packages/apps/reputation-oracle/server/src/common/errors/minio.ts b/packages/apps/reputation-oracle/server/src/common/errors/minio.ts deleted file mode 100644 index 5d2eb82833..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/errors/minio.ts +++ /dev/null @@ -1,7 +0,0 @@ -enum MinioErrorCodes { - NotFound = 'NotFound', -} - -export function isNotFoundError(error: any): boolean { - return error?.code === MinioErrorCodes.NotFound; -} diff --git a/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts b/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts index 0f3c3ba478..9134a66860 100644 --- a/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts @@ -1,9 +1,4 @@ -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - EscrowUtils: { - getEscrow: jest.fn(), - }, -})); +jest.mock('@human-protocol/sdk'); import { EscrowUtils } from '@human-protocol/sdk'; import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; @@ -11,16 +6,18 @@ import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; import { generateContractAddress, generateEthWallet, - generateTestnetChainId, } from '../../../test/fixtures/web3'; import { createExecutionContextMock, ExecutionContextMock, } from '../../../test/mock-creators/nest'; +import { generateTestnetChainId } from '../../modules/web3/fixtures'; import { signMessage } from '../../utils/web3'; import { AuthSignatureRole, SignatureAuthGuard } from './signature.auth'; +const mockedEscrowUtils = jest.mocked(EscrowUtils); + describe('SignatureAuthGuard', () => { it('should throw if empty roles provided in constructor', async () => { let thrownError; @@ -70,9 +67,9 @@ describe('SignatureAuthGuard', () => { const { privateKey, address } = generateEthWallet(); - (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ [name]: address, - }); + } as any); const signature = await signMessage(body, privateKey); @@ -100,9 +97,11 @@ describe('SignatureAuthGuard', () => { const guard = new SignatureAuthGuard([AuthSignatureRole.JOB_LAUNCHER]); const { privateKey, address } = generateEthWallet(); - (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ + + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ launcher: address, - }); + } as any); + const signature = await signMessage( { 'same-signer': 'different-message', @@ -137,10 +136,11 @@ describe('SignatureAuthGuard', () => { ]); const { privateKey, address } = generateEthWallet(); - (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ + + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ launcher: address, exchangeOracle: address, - }); + } as any); const signature = await signMessage(body, privateKey); @@ -173,11 +173,12 @@ describe('SignatureAuthGuard', () => { ]); const { privateKey } = generateEthWallet(); - (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ + + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ launcher: '', exchangeOracle: '', recordingOracle: '', - }); + } as any); const signature = await signMessage(body, privateKey); diff --git a/packages/apps/reputation-oracle/server/src/common/guards/slack.auth.ts b/packages/apps/reputation-oracle/server/src/common/guards/slack.auth.ts new file mode 100644 index 0000000000..b0f5251a59 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/common/guards/slack.auth.ts @@ -0,0 +1,32 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { isValidSlackRequest } from '@slack/bolt'; + +@Injectable() +export abstract class SlackAuthGuard implements CanActivate { + constructor(private readonly signingSecret: string) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + if ( + isValidSlackRequest({ + signingSecret: this.signingSecret, + body: request.rawBody, + headers: { + 'x-slack-signature': request.headers['x-slack-signature'], + 'x-slack-request-timestamp': + request.headers['x-slack-request-timestamp'], + }, + }) + ) { + return true; + } + + throw new UnauthorizedException(); + } +} diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts index 9d57807d6f..5ac5bbb4e8 100644 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts +++ b/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts @@ -63,3 +63,5 @@ export interface AudinoManifest { job_bounty: string; validation: AudinoValidation; } + +export type JobManifest = FortuneManifest | CvatManifest | AudinoManifest; diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/s3.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/s3.ts deleted file mode 100644 index 81e92ae447..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/s3.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class UploadedFile { - public url: string; - public hash: string; -} diff --git a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts index e192d2f4fb..27dd3b2222 100644 --- a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts @@ -22,24 +22,19 @@ export class AuthConfigService { } /** - * The expiration time (in ms) for access tokens. - * Default: 600000 + * The expiration time (in seconds) for access tokens. + * Default: 600 */ get accessTokenExpiresIn(): number { - return ( - +this.configService.get('JWT_ACCESS_TOKEN_EXPIRES_IN', 600) * 1000 - ); + return +this.configService.get('JWT_ACCESS_TOKEN_EXPIRES_IN', 600); } /** - * The expiration time (in ms) for refresh tokens. - * Default: 3600000 + * The expiration time (in seconds) for refresh tokens. + * Default: 3600 */ get refreshTokenExpiresIn(): number { - return ( - +this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', 3600) * - 1000 - ); + return +this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', 3600); } /** @@ -48,8 +43,7 @@ export class AuthConfigService { */ get verifyEmailTokenExpiresIn(): number { return ( - +this.configService.get('VERIFY_EMAIL_TOKEN_EXPIRES_IN', 86400) * - 1000 + +this.configService.get('VERIFY_EMAIL_TOKEN_EXPIRES_IN', 86400) * 1000 ); } @@ -59,10 +53,7 @@ export class AuthConfigService { */ get forgotPasswordExpiresIn(): number { return ( - +this.configService.get( - 'FORGOT_PASSWORD_TOKEN_EXPIRES_IN', - 86400, - ) * 1000 + +this.configService.get('FORGOT_PASSWORD_TOKEN_EXPIRES_IN', 86400) * 1000 ); } diff --git a/packages/apps/reputation-oracle/server/src/config/config.module.ts b/packages/apps/reputation-oracle/server/src/config/config.module.ts index dbccf1bdc3..6289b989b3 100644 --- a/packages/apps/reputation-oracle/server/src/config/config.module.ts +++ b/packages/apps/reputation-oracle/server/src/config/config.module.ts @@ -12,6 +12,7 @@ import { ReputationConfigService } from './reputation-config.service'; import { S3ConfigService } from './s3-config.service'; import { ServerConfigService } from './server-config.service'; import { Web3ConfigService } from './web3-config.service'; +import { SlackConfigService } from './slack-config.service'; @Global() @Module({ @@ -27,6 +28,7 @@ import { Web3ConfigService } from './web3-config.service'; ReputationConfigService, S3ConfigService, ServerConfigService, + SlackConfigService, Web3ConfigService, ], exports: [ @@ -40,6 +42,7 @@ import { Web3ConfigService } from './web3-config.service'; ReputationConfigService, S3ConfigService, ServerConfigService, + SlackConfigService, Web3ConfigService, ], }) diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index d3aeb4682a..8577786884 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -71,14 +71,19 @@ export const envValidator = Joi.object({ REPUTATION_LEVEL_LOW: Joi.number(), REPUTATION_LEVEL_HIGH: Joi.number(), // Encryption - PGP_PRIVATE_KEY: Joi.string(), - PGP_PASSPHRASE: Joi.string(), + PGP_PRIVATE_KEY: Joi.string().required(), + PGP_PASSPHRASE: Joi.string().required(), PGP_ENCRYPT: Joi.string().valid('true', 'false'), // Kyc KYC_API_KEY: Joi.string(), KYC_API_PRIVATE_KEY: Joi.string().required(), KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), - // Human App HUMAN_APP_EMAIL: Joi.string().email().required(), + // Slack notifications + ABUSE_SLACK_WEBHOOK_URL: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .required(), + ABUSE_SLACK_OAUTH_TOKEN: Joi.string().required(), + ABUSE_SLACK_SIGNING_SECRET: Joi.string().required(), }); diff --git a/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts b/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts index 32547f8c59..65e8111159 100644 --- a/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts @@ -16,14 +16,14 @@ export class PGPConfigService { /** * The private key used for PGP encryption or decryption. */ - get privateKey(): string | undefined { - return this.configService.get('PGP_PRIVATE_KEY'); + get privateKey(): string { + return this.configService.getOrThrow('PGP_PRIVATE_KEY'); } /** * The passphrase associated with the PGP private key. */ - get passphrase(): string | undefined { - return this.configService.get('PGP_PASSPHRASE'); + get passphrase(): string { + return this.configService.getOrThrow('PGP_PASSPHRASE'); } } diff --git a/packages/apps/reputation-oracle/server/src/config/server-config.service.ts b/packages/apps/reputation-oracle/server/src/config/server-config.service.ts index 016e6d54fe..db5474877e 100644 --- a/packages/apps/reputation-oracle/server/src/config/server-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/server-config.service.ts @@ -50,10 +50,16 @@ export class ServerConfigService { } /** - * The minimum validity period (in days) for a qualification. - * Default: 1 day + * The minimum validity period (in ms) for a qualification. + * Default: 1 day (24 * 60 * 60 * 1000 ms) */ get qualificationMinValidity(): number { - return +this.configService.get('QUALIFICATION_MIN_VALIDITY', 1); + return ( + +this.configService.get('QUALIFICATION_MIN_VALIDITY', 1) * + 24 * + 60 * + 60 * + 1000 + ); } } diff --git a/packages/apps/reputation-oracle/server/src/config/slack-config.service.ts b/packages/apps/reputation-oracle/server/src/config/slack-config.service.ts new file mode 100644 index 0000000000..dac95dcf77 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/slack-config.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SlackConfigService { + constructor(private configService: ConfigService) {} + get abuseWebhookUrl(): string { + return this.configService.getOrThrow('ABUSE_SLACK_WEBHOOK_URL'); + } + get abuseOauthToken(): string { + return this.configService.getOrThrow('ABUSE_SLACK_OAUTH_TOKEN'); + } + get abuseSigningSecret(): string { + return this.configService.getOrThrow('ABUSE_SLACK_SIGNING_SECRET'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/database.module.ts b/packages/apps/reputation-oracle/server/src/database/database.module.ts index 4326dfc0fa..94461cf7a1 100644 --- a/packages/apps/reputation-oracle/server/src/database/database.module.ts +++ b/packages/apps/reputation-oracle/server/src/database/database.module.ts @@ -16,6 +16,7 @@ import { WebhookIncomingEntity } from '../modules/webhook/webhook-incoming.entit import { WebhookOutgoingEntity } from '../modules/webhook/webhook-outgoing.entity'; import { EscrowCompletionEntity } from '../modules/escrow-completion/escrow-completion.entity'; import { EscrowPayoutsBatchEntity } from '../modules/escrow-completion/escrow-payouts-batch.entity'; +import { AbuseEntity } from '../modules/abuse/abuse.entity'; import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; @@ -61,6 +62,7 @@ import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; synchronize: false, migrationsRun: false, entities: [ + AbuseEntity, WebhookIncomingEntity, WebhookOutgoingEntity, EscrowCompletionEntity, diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1742462400299-addAbuse.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1742462400299-addAbuse.ts new file mode 100644 index 0000000000..0642cc46d1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1742462400299-addAbuse.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAbuse1742462400299 implements MigrationInterface { + name = 'AddAbuse1742462400299'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "hmt"."abuses_status_enum" AS ENUM('pending', 'completed', 'failed', 'notified')`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."abuses_decision_enum" AS ENUM('rejected', 'accepted')`, + ); + await queryRunner.query( + `CREATE TABLE "hmt"."abuses" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, "chain_id" integer NOT NULL, "escrow_address" character varying NOT NULL, "status" "hmt"."abuses_status_enum" NOT NULL, "decision" "hmt"."abuses_decision_enum", "amount" numeric(30,18), "user_id" integer NOT NULL, "retries_count" integer NOT NULL, "wait_until" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_8cfbf5b6d26e83e4fd5955c8c8b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_023d33d90733aa64f612995657" ON "hmt"."abuses" ("chain_id", "escrow_address") `, + ); + await queryRunner.query( + `ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum" RENAME TO "cron-jobs_cron_job_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum" AS ENUM('process-pending-incoming-webhook', 'process-pending-outgoing-webhook', 'process-pending-escrow-completion-tracking', 'process-paid-escrow-completion-tracking', 'process-awaiting-escrow-payouts', 'process-requested-abuse', 'process-classified-abuse')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."cron-jobs" ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "hmt"."cron-jobs_cron_job_type_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" ADD CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" DROP CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum_old" AS ENUM('process-pending-incoming-webhook', 'process-pending-outgoing-webhook', 'process-pending-escrow-completion-tracking', 'process-paid-escrow-completion-tracking', 'process-awaiting-escrow-payouts')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."cron-jobs" ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum_old" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."cron-jobs_cron_job_type_enum"`); + await queryRunner.query( + `ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum_old" RENAME TO "cron-jobs_cron_job_type_enum"`, + ); + await queryRunner.query( + `DROP INDEX "hmt"."IDX_023d33d90733aa64f612995657"`, + ); + await queryRunner.query(`DROP TABLE "hmt"."abuses"`); + await queryRunner.query(`DROP TYPE "hmt"."abuses_decision_enum"`); + await queryRunner.query(`DROP TYPE "hmt"."abuses_status_enum"`); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1744201451282-qualificationAdjustments.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1744201451282-qualificationAdjustments.ts new file mode 100644 index 0000000000..303a5793fc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1744201451282-qualificationAdjustments.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class QualificationAdjustments1744201451282 + implements MigrationInterface +{ + name = 'QualificationAdjustments1744201451282'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_bfa80c2767c180533958bf9c971"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ALTER COLUMN "user_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ALTER COLUMN "qualification_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "title"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "title" character varying(50) NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "description"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "description" character varying(200) NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "expires_at"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "expires_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_f40f66a6ba16b27a81ec48f566" ON "hmt"."user_qualifications" ("user_id", "qualification_id") `, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_bfa80c2767c180533958bf9c971" FOREIGN KEY ("qualification_id") REFERENCES "hmt"."qualifications"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_bfa80c2767c180533958bf9c971"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5"`, + ); + await queryRunner.query( + `DROP INDEX "hmt"."IDX_f40f66a6ba16b27a81ec48f566"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "expires_at"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "expires_at" TIMESTAMP`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "description"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "description" text NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" DROP COLUMN "title"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."qualifications" ADD "title" text NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ALTER COLUMN "qualification_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ALTER COLUMN "user_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_bfa80c2767c180533958bf9c971" FOREIGN KEY ("qualification_id") REFERENCES "hmt"."qualifications"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.spec.ts b/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.spec.ts new file mode 100644 index 0000000000..39eaf3e24b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.spec.ts @@ -0,0 +1,135 @@ +import { HttpService } from '@nestjs/axios'; +import { SlackBotApp } from './slack-bot-app'; +import { faker } from '@faker-js/faker'; +import { + createHttpServiceMock, + createHttpServiceRequestError, + createHttpServiceResponse, +} from '../../../test/mock-creators/nest'; + +const mockHttpService = createHttpServiceMock(); + +describe('SlackBotApp', () => { + let slackBotApp: SlackBotApp; + + const config = { + webhookUrl: faker.internet.url(), + oauthToken: faker.internet.jwt(), + }; + + beforeAll(async () => { + slackBotApp = new SlackBotApp( + mockHttpService as unknown as HttpService, + config, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('sendNotification', () => { + it('should send a notification successfully', async () => { + mockHttpService.post.mockReturnValueOnce(createHttpServiceResponse(200)); + const message = { text: 'Test notification' }; + + await expect( + slackBotApp.sendNotification(message), + ).resolves.not.toThrow(); + + expect(mockHttpService.post).toHaveBeenCalledWith( + config.webhookUrl, + message, + ); + }); + + it('should throw an error if sending the notification fails', async () => { + mockHttpService.post.mockReturnValueOnce( + createHttpServiceRequestError(new Error()), + ); + await expect( + slackBotApp.sendNotification({ text: 'Test' }), + ).rejects.toThrow('Error sending Slack notification'); + }); + }); + + describe('openModal', () => { + it('should open a modal successfully', async () => { + mockHttpService.post.mockReturnValueOnce( + createHttpServiceResponse(200, { + ok: true, + }), + ); + + const triggerId = faker.word.sample(); + const modalView: any = { + type: 'modal', + title: { type: 'plain_text', text: 'Test' }, + }; + + await expect( + slackBotApp.openModal(triggerId, modalView), + ).resolves.not.toThrow(); + + expect(mockHttpService.post).toHaveBeenCalledWith( + 'https://slack.com/api/views.open', + { + trigger_id: triggerId, + view: modalView, + }, + { + headers: { + Authorization: `Bearer ${config.oauthToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should throw an error if opening the modal fails', async () => { + mockHttpService.post.mockReturnValueOnce( + createHttpServiceResponse(200, { + ok: false, + error: 'invalid_trigger', + }), + ); + + const triggerId = faker.word.sample(); + const modalView: any = { + type: 'modal', + title: { type: 'plain_text', text: 'Test' }, + }; + + await expect(slackBotApp.openModal(triggerId, modalView)).rejects.toThrow( + 'Error opening Slack modal', + ); + }); + }); + + describe('updateMessage', () => { + it('should update a message successfully', async () => { + mockHttpService.post.mockReturnValueOnce(createHttpServiceResponse(200)); + const responseUrl = faker.internet.url(); + const text = faker.lorem.sentence(); + + await expect( + slackBotApp.updateMessage(responseUrl, text), + ).resolves.not.toThrow(); + + expect(mockHttpService.post).toHaveBeenCalledWith(responseUrl, { text }); + }); + + it('should throw an error if updating the message fails', async () => { + mockHttpService.post.mockReturnValueOnce( + createHttpServiceRequestError(new Error()), + ); + + const responseUrl = faker.internet.url(); + const text = faker.lorem.sentence(); + + await expect( + slackBotApp.updateMessage(responseUrl, text), + ).rejects.toThrow('Error updating Slack message'); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.ts b/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.ts new file mode 100644 index 0000000000..39b92d3039 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/integrations/slack-bot-app/slack-bot-app.ts @@ -0,0 +1,74 @@ +import { HttpService } from '@nestjs/axios'; +import { View, ViewsOpenResponse } from '@slack/web-api'; +import { IncomingWebhookSendArguments } from '@slack/webhook'; +import { firstValueFrom } from 'rxjs'; +import logger from '../../logger'; +import * as httpUtils from '../../utils/http'; + +export class SlackBotApp { + private readonly logger = logger.child({ context: SlackBotApp.name }); + constructor( + private readonly httpService: HttpService, + protected readonly config: { webhookUrl: string; oauthToken: string }, + ) {} + + async sendNotification(message: IncomingWebhookSendArguments): Promise { + try { + await firstValueFrom( + this.httpService.post(this.config.webhookUrl, message), + ); + } catch (error) { + const formattedError = httpUtils.formatAxiosError(error); + const errorMessage = 'Error sending Slack notification'; + this.logger.error(errorMessage, { + error: formattedError, + }); + throw new Error(errorMessage); + } + } + + async openModal(triggerId: string, modalView: View): Promise { + try { + const response = await firstValueFrom( + this.httpService.post( + 'https://slack.com/api/views.open', + { + trigger_id: triggerId, + view: modalView, + }, + { + headers: { + Authorization: `Bearer ${this.config.oauthToken}`, + 'Content-Type': 'application/json', + }, + }, + ), + ); + + if (!response.data.ok) { + this.logger.error('Error opening Slack modal:', response.data); + throw new Error('Error opening Slack modal'); + } + } catch (error) { + const formattedError = httpUtils.formatAxiosError(error); + const errorMessage = 'Error opening Slack modal'; + this.logger.error(errorMessage, { + error: formattedError, + }); + throw new Error(errorMessage); + } + } + + async updateMessage(responseUrl: string, text: string): Promise { + try { + await firstValueFrom(this.httpService.post(responseUrl, { text })); + } catch (error) { + const formattedError = httpUtils.formatAxiosError(error); + const errorMessage = 'Error updating Slack message'; + this.logger.error(errorMessage, { + error: formattedError, + }); + throw new Error(errorMessage); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/main.ts b/packages/apps/reputation-oracle/server/src/main.ts index 1629824e8c..b7f598ce6f 100644 --- a/packages/apps/reputation-oracle/server/src/main.ts +++ b/packages/apps/reputation-oracle/server/src/main.ts @@ -8,6 +8,15 @@ import { AppModule } from './app.module'; import { useContainer } from 'class-validator'; import { ServerConfigService } from './config/server-config.service'; import logger, { nestLoggerOverride } from './logger'; +import { IncomingMessage, ServerResponse } from 'http'; + +function rawBodyMiddleware( + req: any, + _res: ServerResponse, + buf: Buffer, +): void { + req.rawBody = buf.toString(); +} async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -16,8 +25,19 @@ async function bootstrap() { }); useContainer(app.select(AppModule), { fallbackOnErrors: true }); - app.use(json({ limit: '5mb' })); - app.use(urlencoded({ limit: '5mb', extended: true })); + app.use( + json({ + limit: '5mb', + verify: rawBodyMiddleware, + }), + ); + app.use( + urlencoded({ + limit: '5mb', + extended: true, + verify: rawBodyMiddleware, + }), + ); const config = new DocumentBuilder() .addBearerAuth() diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.controller.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.controller.ts new file mode 100644 index 0000000000..813bf83a63 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { + AbuseResponseDto, + ReportAbuseDto, + SlackInteractionDto, +} from './abuse.dto'; +import { AbuseService } from './abuse.service'; +import { RequestWithUser } from '../../common/interfaces/request'; +import { Public } from '../../common/decorators'; +import { AbuseRepository } from './abuse.repository'; +import { AbuseSlackAuthGuard } from './abuse.slack-auth.guard'; + +@ApiTags('Abuse') +@Controller('/abuse') +export class AbuseController { + constructor( + private readonly abuseService: AbuseService, + private readonly abuseRepository: AbuseRepository, + ) {} + + @ApiBearerAuth() + @Post('/report') + @HttpCode(200) + @ApiOperation({ + summary: 'Report abuse', + description: 'Endpoint to report an identified abuse.', + }) + @ApiBody({ type: ReportAbuseDto }) + @ApiResponse({ + status: 200, + description: 'Report successfully received', + }) + async reportAbuse( + @Req() request: RequestWithUser, + @Body() data: ReportAbuseDto, + ): Promise { + await this.abuseService.reportAbuse({ + escrowAddress: data.escrowAddress, + chainId: data.chainId, + userId: request.user.id, + }); + } + + @ApiBearerAuth() + @Get('/reports') + @HttpCode(200) + @ApiOperation({ + summary: 'Get all abuse reports by user', + description: + 'Endpoint to retrieve all abuse entities created by the authenticated user.', + }) + @ApiResponse({ + status: 200, + description: 'List of abuse entities created by the user', + }) + async getUserAbuseReports( + @Req() request: RequestWithUser, + ): Promise { + const abuseEntities = await this.abuseRepository.findByUserId( + request.user.id, + ); + return abuseEntities.map((abuseEntity) => { + return { + id: abuseEntity.id, + escrowAddress: abuseEntity.escrowAddress, + chainId: abuseEntity.chainId, + status: abuseEntity.status, + }; + }); + } + + @Public() + @UseGuards(AbuseSlackAuthGuard) + @Post('/slack-interactions') + @HttpCode(200) + @ApiOperation({ + summary: 'Receive slack interactions', + description: 'Endpoint to receive slack interactions.', + }) + @ApiResponse({ + status: 200, + description: 'Interaction successfully received', + }) + @HttpCode(200) + async receiveInteractions( + @Body() data: SlackInteractionDto, + ): Promise { + return this.abuseService.processSlackInteraction(JSON.parse(data.payload)); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.dto.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.dto.ts new file mode 100644 index 0000000000..4bb74de154 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.dto.ts @@ -0,0 +1,39 @@ +import { ChainId } from '@human-protocol/sdk'; +import { ApiProperty } from '@nestjs/swagger'; +import { AbuseStatus } from './constants'; +import { IsChainId } from '../../common/validators'; +import { IsEthereumAddress, IsString } from 'class-validator'; + +export class ReportAbuseDto { + @ApiProperty({ name: 'chain_id' }) + @IsChainId() + chainId: ChainId; + + @ApiProperty({ name: 'escrow_address' }) + @IsEthereumAddress() + escrowAddress: string; +} + +export class AbuseResponseDto { + @ApiProperty({ description: 'Unique identifier of the abuse entity' }) + id: number; + + @ApiProperty({ description: 'Escrow address associated with the abuse' }) + escrowAddress: string; + + @ApiProperty({ description: 'Chain ID where the abuse occurred' }) + chainId: ChainId; + + @ApiProperty({ description: 'Current status of the abuse report' }) + status: AbuseStatus; +} + +export class SlackInteractionDto { + @ApiProperty({ + description: 'The Slack interaction payload as a stringified JSON object', + example: + '{"type":"interactive_message","callback_id":"123","actions":[{"value":"ACCEPTED"}]}', + }) + @IsString() + payload: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts new file mode 100644 index 0000000000..acc9b51058 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts @@ -0,0 +1,46 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { DATABASE_SCHEMA_NAME } from '../../common/constants'; +import { BaseEntity } from '../../database/base.entity'; +import { UserEntity } from '../user/user.entity'; +import { ChainId } from '@human-protocol/sdk'; +import { AbuseDecision, AbuseStatus } from './constants'; + +@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'abuses' }) +@Index(['chainId', 'escrowAddress'], { unique: true }) +export class AbuseEntity extends BaseEntity { + @Column({ type: 'int' }) + chainId: ChainId; + + @Column({ type: 'varchar' }) + escrowAddress: string; + + @Column({ + type: 'enum', + enum: AbuseStatus, + }) + status: AbuseStatus; + + @Column({ + type: 'enum', + enum: AbuseDecision, + nullable: true, + }) + decision: AbuseDecision | null; + + @Column({ type: 'decimal', precision: 30, scale: 18, nullable: true }) + amount: number | null; + + @JoinColumn() + @ManyToOne('UserEntity', { nullable: false }) + user?: UserEntity; + + @Column({ type: 'int' }) + userId: number; + + @Column({ type: 'int' }) + retriesCount: number; + + @Column({ type: 'timestamptz' }) + waitUntil: Date; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts new file mode 100644 index 0000000000..f47f4b0349 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { AbuseSlackBot } from './abuse.slack-bot'; +import { AbuseRepository } from './abuse.repository'; +import { AbuseService } from './abuse.service'; +import { Web3Module } from '../web3/web3.module'; +import { ReputationModule } from '../reputation/reputation.module'; +import { WebhookOutgoingModule } from '../webhook/webhook-outgoing.module'; +import { AbuseController } from './abuse.controller'; +import { AbuseSlackAuthGuard } from './abuse.slack-auth.guard'; + +@Module({ + imports: [HttpModule, Web3Module, ReputationModule, WebhookOutgoingModule], + providers: [ + AbuseRepository, + AbuseService, + AbuseSlackBot, + AbuseSlackAuthGuard, + ], + exports: [AbuseService], + controllers: [AbuseController], +}) +export class AbuseModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.repository.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.repository.ts new file mode 100644 index 0000000000..f07647b0b8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { + DataSource, + FindManyOptions, + IsNull, + LessThanOrEqual, + Not, +} from 'typeorm'; +import { AbuseStatus } from './constants'; +import { ServerConfigService } from '../../config/server-config.service'; +import { BaseRepository } from '../../database/base.repository'; +import { AbuseEntity } from './abuse.entity'; + +type FindOptions = { + relations?: FindManyOptions['relations']; +}; + +@Injectable() +export class AbuseRepository extends BaseRepository { + constructor( + dataSource: DataSource, + private readonly serverConfigService: ServerConfigService, + ) { + super(AbuseEntity, dataSource); + } + + async findToClassify(): Promise { + return this.find({ + where: { + status: AbuseStatus.PENDING, + retriesCount: LessThanOrEqual(this.serverConfigService.maxRetryCount), + waitUntil: LessThanOrEqual(new Date()), + }, + order: { + createdAt: 'ASC', + }, + }); + } + + async findClassified(options: FindOptions = {}): Promise { + return this.find({ + where: { + status: AbuseStatus.NOTIFIED, + decision: Not(IsNull()), + retriesCount: LessThanOrEqual(this.serverConfigService.maxRetryCount), + waitUntil: LessThanOrEqual(new Date()), + }, + order: { + createdAt: 'ASC', + }, + relations: options.relations, + }); + } + + async findByUserId(userId: number): Promise { + return this.find({ where: { userId } }); + } + + async findOneById(id: number): Promise { + return this.findOne({ where: { id } }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts new file mode 100644 index 0000000000..141fcec665 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts @@ -0,0 +1,476 @@ +jest.mock('@human-protocol/sdk'); + +import { faker } from '@faker-js/faker'; +import { createMock } from '@golevelup/ts-jest'; +import { + EscrowUtils, + IOperator, + OperatorUtils, + StakingClient, +} from '@human-protocol/sdk'; +import { EscrowData } from '@human-protocol/sdk/dist/graphql'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { EventType, ReputationEntityType } from '../../common/enums'; +import { PostgresErrorCodes } from '../../common/enums/database'; +import { DatabaseError } from '../../common/errors/database'; +import { ServerConfigService } from '../../config/server-config.service'; +import { ReputationService } from '../reputation/reputation.service'; +import { generateTestnetChainId } from '../web3/fixtures'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; +import { AbuseRepository } from './abuse.repository'; +import { AbuseService } from './abuse.service'; +import { AbuseSlackBot } from './abuse.slack-bot'; +import { AbuseDecision, AbuseStatus } from './constants'; +import { generateAbuseEntity } from './fixtures'; + +const fakeAddress = faker.finance.ethereumAddress(); + +const mockAbuseRepository = createMock(); +const mockAbuseSlackBot = createMock(); +const mockReputationService = createMock(); +const mockWeb3Service = createMock(); +const mockWebhookOutgoingService = createMock(); + +const mockedStakingClient = jest.mocked(StakingClient); +const mockedOperatorUtils = jest.mocked(OperatorUtils); +const mockedEscrowUtils = jest.mocked(EscrowUtils); + +describe('AbuseService', () => { + let abuseService: AbuseService; + + const escrowAddress = faker.finance.ethereumAddress(); + const chainId = generateTestnetChainId(); + const webhookUrl1 = faker.internet.url(); + const webhookUrl2 = faker.internet.url(); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + AbuseService, + ConfigService, + ServerConfigService, + { + provide: AbuseSlackBot, + useValue: mockAbuseSlackBot, + }, + { + provide: Web3Service, + useValue: mockWeb3Service, + }, + { provide: AbuseRepository, useValue: mockAbuseRepository }, + { + provide: ReputationService, + useValue: mockReputationService, + }, + { + provide: WebhookOutgoingService, + useValue: mockWebhookOutgoingService, + }, + ], + }).compile(); + + abuseService = moduleRef.get(AbuseService); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('reportAbuse', () => { + it('should create a new abuse entity', async () => { + const userId = faker.number.int(); + + await abuseService.reportAbuse({ + escrowAddress, + chainId, + userId, + }); + + expect(mockAbuseRepository.createUnique).toHaveBeenCalledWith({ + escrowAddress: escrowAddress, + chainId: chainId, + userId: userId, + retriesCount: 0, + status: AbuseStatus.PENDING, + waitUntil: expect.any(Date), + }); + }); + }); + + describe('processSlackInteraction', () => { + it('should send an Abuse Report Modal to Slack if the decision is accepted', async () => { + const abuseEntity = generateAbuseEntity({ status: AbuseStatus.NOTIFIED }); + + const dto = { + callback_id: abuseEntity.id, + chainId, + type: 'interactive_message', + actions: [{ value: AbuseDecision.ACCEPTED }], + trigger_id: faker.string.uuid(), + response_url: faker.internet.url(), + }; + + mockAbuseRepository.findOneById.mockResolvedValueOnce(abuseEntity); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + launcher: fakeAddress, + } as EscrowData); + const amount = faker.number.int(); + mockedOperatorUtils.getOperator.mockResolvedValueOnce({ + amountStaked: BigInt(amount), + } as IOperator); + + await abuseService.processSlackInteraction(dto as any); + + expect(mockAbuseSlackBot.triggerAbuseReportModal).toHaveBeenCalledTimes( + 1, + ); + expect(mockAbuseSlackBot.triggerAbuseReportModal).toHaveBeenCalledWith({ + abuseId: abuseEntity.id, + escrowAddress: abuseEntity.escrowAddress, + chainId: abuseEntity.chainId, + maxAmount: amount, + triggerId: dto.trigger_id, + responseUrl: dto.response_url, + }); + }); + + it('should update Slack message and entity if the decision is submitted via view_submission', async () => { + const abuseEntity = generateAbuseEntity({ status: AbuseStatus.NOTIFIED }); + + const dto = { + chainId, + type: 'view_submission', + view: { + callback_id: abuseEntity.id, + private_metadata: JSON.stringify({ + responseUrl: faker.internet.url(), + }), + state: { + values: { + quantity_input: { quantity: { value: 10 } }, + }, + }, + }, + }; + + mockAbuseRepository.findOneById.mockResolvedValueOnce(abuseEntity); + + await abuseService.processSlackInteraction(dto as any); + + expect(mockAbuseSlackBot.updateMessage).toHaveBeenCalledTimes(1); + expect(mockAbuseSlackBot.updateMessage).toHaveBeenCalledWith( + JSON.parse(dto.view.private_metadata).responseUrl, + `Abuse accepted. Escrow: ${abuseEntity.escrowAddress}, ChainId: ${abuseEntity.chainId}, Slashed amount: 10 HMT`, + ); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...abuseEntity, + decision: AbuseDecision.ACCEPTED, + amount: 10, + retriesCount: 0, + }); + }); + + it('should update the entity if the decision is rejected via interactive_message', async () => { + const abuseEntity = generateAbuseEntity({ status: AbuseStatus.NOTIFIED }); + + const dto = { + callback_id: abuseEntity.id, + chainId, + type: 'interactive_message', + actions: [{ value: AbuseDecision.REJECTED }], + }; + + mockAbuseRepository.findOneById.mockResolvedValueOnce(abuseEntity); + + await abuseService.processSlackInteraction(dto as any); + + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...abuseEntity, + decision: AbuseDecision.REJECTED, + retriesCount: 0, + }); + }); + + it('should throw an error if the abuse entity is not found', async () => { + const dto = { + callback_id: faker.number.int(), + chainId, + type: 'interactive_message', + actions: [{ value: AbuseDecision.ACCEPTED }], + }; + + mockAbuseRepository.findOneById.mockResolvedValueOnce(null); + + await expect( + abuseService.processSlackInteraction(dto as any), + ).rejects.toThrow('Abuse entity not found'); + }); + + it('should throw an error if Callback ID is not found', async () => { + const dto = { + chainId, + type: 'interactive_message', + actions: [{ value: AbuseDecision.ACCEPTED }], + }; + + mockAbuseRepository.findOneById.mockResolvedValueOnce(null); + + await expect( + abuseService.processSlackInteraction(dto as any), + ).rejects.toThrow( + 'Callback ID is missing from the Slack interaction data', + ); + }); + }); + + describe('processAbuseRequests', () => { + it('should process pending abuse requests and send notifications', async () => { + const mockAbuseEntities = [generateAbuseEntity(), generateAbuseEntity()]; + + mockAbuseRepository.findToClassify.mockResolvedValueOnce( + mockAbuseEntities, + ); + mockedEscrowUtils.getEscrow + .mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData) + .mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator + .mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator) + .mockResolvedValueOnce({ + webhookUrl: webhookUrl2, + } as IOperator); + mockAbuseSlackBot.sendAbuseNotification + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseSlackBot.sendAbuseNotification).toHaveBeenCalledTimes(2); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + status: AbuseStatus.NOTIFIED, + }); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[1], + status: AbuseStatus.NOTIFIED, + }); + }); + + it('should handle errors when sending notifications fails', async () => { + const mockAbuseEntities = [generateAbuseEntity({ retriesCount: 0 })]; + + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator.mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator); + mockAbuseRepository.findToClassify.mockResolvedValueOnce( + mockAbuseEntities, + ); + + mockAbuseSlackBot.sendAbuseNotification.mockRejectedValueOnce( + new Error(), + ); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + retriesCount: 1, + }); + }); + + it('should handle errors when createOutgoingWebhook fails', async () => { + const mockAbuseEntities = [generateAbuseEntity({ retriesCount: 0 })]; + + mockAbuseRepository.findToClassify.mockResolvedValueOnce( + mockAbuseEntities, + ); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator.mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator); + + mockWebhookOutgoingService.createOutgoingWebhook.mockRejectedValueOnce( + new DatabaseError('Failed to create webhook'), + ); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + retriesCount: 1, + }); + }); + + it('should continue if createOutgoingWebhook throws a duplicated error', async () => { + const mockAbuseEntities = [generateAbuseEntity(), generateAbuseEntity()]; + + mockAbuseRepository.findToClassify.mockResolvedValueOnce( + mockAbuseEntities, + ); + mockedEscrowUtils.getEscrow + .mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData) + .mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator + .mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator) + .mockResolvedValueOnce({ + webhookUrl: webhookUrl2, + } as IOperator); + + mockWebhookOutgoingService.createOutgoingWebhook.mockRejectedValueOnce( + new DatabaseError(PostgresErrorCodes.Duplicated), + ); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + status: AbuseStatus.NOTIFIED, + }); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[1], + status: AbuseStatus.NOTIFIED, + }); + }); + + it('should set abuse status to failed after exceeding 5 retry attempts', async () => { + const mockAbuseEntities = [generateAbuseEntity({ retriesCount: 5 })]; + + mockAbuseRepository.findToClassify.mockResolvedValueOnce( + mockAbuseEntities, + ); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + retriesCount: 5, + status: AbuseStatus.FAILED, + }); + }); + + it('should handle empty results from findToClassify', async () => { + mockAbuseRepository.findToClassify.mockResolvedValueOnce([]); + + await abuseService.processAbuseRequests(); + + expect(mockAbuseRepository.findToClassify).toHaveBeenCalledTimes(1); + expect(mockAbuseSlackBot.sendAbuseNotification).not.toHaveBeenCalled(); + expect(mockAbuseRepository.updateOne).not.toHaveBeenCalled(); + }); + }); + + describe('processClassifiedAbuses', () => { + it('should process accepted abuses', async () => { + const mockAbuseEntities = [ + generateAbuseEntity({ decision: AbuseDecision.ACCEPTED }), + ]; + + mockAbuseRepository.findClassified.mockResolvedValueOnce( + mockAbuseEntities, + ); + const slashMock = jest.fn(); + mockedStakingClient.build.mockResolvedValueOnce({ + slash: slashMock, + } as unknown as StakingClient); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + launcher: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator.mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator); + mockWebhookOutgoingService.createOutgoingWebhook.mockResolvedValueOnce( + undefined, + ); + + await abuseService.processClassifiedAbuses(); + + expect(mockAbuseRepository.findClassified).toHaveBeenCalledTimes(1); + // expect(slashMock).toHaveBeenCalledWith( + // expect.any(String), + // expect.any(String), + // chainId, + // escrowAddress, + // expect.any(Number), + // ); + expect( + mockWebhookOutgoingService.createOutgoingWebhook, + ).toHaveBeenCalledWith( + { + escrowAddress: mockAbuseEntities[0].escrowAddress, + chainId: mockAbuseEntities[0].chainId, + eventType: EventType.ABUSE_DETECTED, + }, + expect.any(String), + ); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + status: AbuseStatus.COMPLETED, + }); + }); + + it('should process rejected abuses', async () => { + const mockAbuseEntities = [ + generateAbuseEntity({ decision: AbuseDecision.REJECTED }), + ]; + + mockAbuseRepository.findClassified.mockResolvedValueOnce( + mockAbuseEntities, + ); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + exchangeOracle: fakeAddress, + } as EscrowData); + mockedOperatorUtils.getOperator.mockResolvedValueOnce({ + webhookUrl: webhookUrl1, + } as IOperator); + mockReputationService.decreaseReputation.mockResolvedValueOnce(undefined); + + await abuseService.processClassifiedAbuses(); + + expect(mockAbuseRepository.findClassified).toHaveBeenCalledTimes(1); + expect(mockReputationService.decreaseReputation).toHaveBeenCalledWith( + mockAbuseEntities[0].chainId, + mockAbuseEntities[0].user?.evmAddress, + ReputationEntityType.WORKER, + ); + expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ + ...mockAbuseEntities[0], + status: AbuseStatus.COMPLETED, + }); + }); + + it('should handle empty results from findClassified', async () => { + mockAbuseRepository.findClassified.mockResolvedValueOnce([]); + + await abuseService.processClassifiedAbuses(); + + expect(mockAbuseRepository.findClassified).toHaveBeenCalledTimes(1); + expect( + mockWebhookOutgoingService.createOutgoingWebhook, + ).not.toHaveBeenCalled(); + expect(mockAbuseRepository.updateOne).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts new file mode 100644 index 0000000000..5dc9e16ab7 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts @@ -0,0 +1,304 @@ +import { + ChainId, + EscrowUtils, + OperatorUtils, + StakingClient, +} from '@human-protocol/sdk'; +import { Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; +import { EventType, ReputationEntityType } from '../../common/enums'; +import { isDuplicatedError } from '../../common/errors/database'; +import { ServerConfigService } from '../../config/server-config.service'; +import logger from '../../logger'; +import { ReputationService } from '../reputation/reputation.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; +import { AbuseEntity } from './abuse.entity'; +import { AbuseRepository } from './abuse.repository'; +import { AbuseDecision, AbuseStatus } from './constants'; +import { + isInteractiveMessage, + isViewSubmission, + ReportAbuseInput, + SlackInteraction, +} from './types'; +import { AbuseSlackBot } from './abuse.slack-bot'; + +@Injectable() +export class AbuseService { + private readonly logger = logger.child({ + context: AbuseService.name, + }); + + constructor( + private readonly abuseSlackBot: AbuseSlackBot, + private readonly abuseRepository: AbuseRepository, + private readonly web3Service: Web3Service, + private readonly serverConfigService: ServerConfigService, + private readonly reputationService: ReputationService, + private readonly webhookOutgoingService: WebhookOutgoingService, + ) {} + + async reportAbuse(data: ReportAbuseInput): Promise { + const abuseEntity = new AbuseEntity(); + abuseEntity.escrowAddress = data.escrowAddress; + abuseEntity.chainId = data.chainId; + abuseEntity.userId = data.userId; + abuseEntity.status = AbuseStatus.PENDING; + abuseEntity.retriesCount = 0; + abuseEntity.waitUntil = new Date(); + + await this.abuseRepository.createUnique(abuseEntity); + } + + private async slashAccount(data: { + slasher: string; + staker: string; + chainId: ChainId; + escrowAddress: string; + amount: number; + }) { + const signer = this.web3Service.getSigner(data.chainId); + const stakingClient = await StakingClient.build(signer); + + // TODO: Slash account + return; + await stakingClient.slash( + data.slasher, + data.staker, + data.escrowAddress, + BigInt(ethers.parseUnits(data.amount.toString(), 'ether')), + ); + } + + async processSlackInteraction(data: SlackInteraction): Promise { + const abuseId = Number( + isViewSubmission(data) ? data.view.callback_id : data.callback_id, + ); + + if (!abuseId) { + this.logger.error( + 'Callback ID is missing from the Slack interaction data:', + data, + ); + throw new Error( + 'Callback ID is missing from the Slack interaction data.', + ); + } + + const abuseEntity = await this.abuseRepository.findOneById(abuseId); + if (!abuseEntity) { + this.logger.error('Abuse entity not found. Abuse id:', abuseId); + throw new Error(`Abuse entity not found. Abuse id: ${abuseId}`); + } + + if ( + isInteractiveMessage(data) && + data.actions[0].value === AbuseDecision.ACCEPTED + ) { + const escrow = await EscrowUtils.getEscrow( + abuseEntity.chainId, + abuseEntity.escrowAddress, + ); + const maxAmount = Number( + (await OperatorUtils.getOperator(abuseEntity.chainId, escrow.launcher)) + .amountStaked, + ); + await this.abuseSlackBot.triggerAbuseReportModal({ + abuseId: abuseEntity.id, + escrowAddress: abuseEntity.escrowAddress, + chainId: abuseEntity.chainId, + maxAmount: maxAmount, + triggerId: data.trigger_id, + responseUrl: data.response_url, + }); + return ''; + } else if (isViewSubmission(data)) { + const privateMetadata = JSON.parse(data.view.private_metadata); + const responseUrl = privateMetadata.responseUrl; + abuseEntity.decision = AbuseDecision.ACCEPTED; + abuseEntity.amount = data.view.state.values.quantity_input.quantity.value; + abuseEntity.retriesCount = 0; + + await this.abuseSlackBot.updateMessage( + responseUrl, + `Abuse ${abuseEntity.decision.toLowerCase()}. Escrow: ${abuseEntity.escrowAddress}, ChainId: ${abuseEntity.chainId}, Slashed amount: ${abuseEntity.amount} HMT`, + ); + await this.abuseRepository.updateOne(abuseEntity); + return ''; + } else { + abuseEntity.decision = data.actions[0].value as AbuseDecision; + abuseEntity.retriesCount = 0; + await this.abuseRepository.updateOne(abuseEntity); + return `Abuse ${abuseEntity.decision.toLowerCase()}. Escrow: ${abuseEntity.escrowAddress}, ChainId: ${abuseEntity.chainId}`; + } + } + + /** + * Handles errors that occur during abuse processing. + * It logs the error and, based on retry count, updates the abuse status accordingly. + * @param abuseEntity - The entity representing the abuse data. + * @param error - The error object thrown during processing. + * @returns {Promise} - Returns a promise that resolves when the operation is complete. + */ + private async handleAbuseError(abuseEntity: AbuseEntity): Promise { + if (abuseEntity.retriesCount >= this.serverConfigService.maxRetryCount) { + abuseEntity.status = AbuseStatus.FAILED; + } else { + abuseEntity.waitUntil = new Date(); + abuseEntity.retriesCount = abuseEntity.retriesCount + 1; + } + + await this.abuseRepository.updateOne(abuseEntity); + } + + async processAbuseRequests(): Promise { + const abuseEntities = await this.abuseRepository.findToClassify(); + + for (const abuseEntity of abuseEntities) { + try { + const escrow = await EscrowUtils.getEscrow( + abuseEntity.chainId, + abuseEntity.escrowAddress, + ); + const webhookUrl = ( + await OperatorUtils.getOperator( + abuseEntity.chainId, + escrow.exchangeOracle as string, + ) + ).webhookUrl as string; + + const webhookPayload = { + chainId: abuseEntity.chainId, + escrowAddress: abuseEntity.escrowAddress, + eventType: EventType.ABUSE_DETECTED, + }; + + try { + await this.webhookOutgoingService.createOutgoingWebhook( + webhookPayload, + webhookUrl, + ); + } catch (error) { + if (!isDuplicatedError(error)) { + this.logger.error('Failed to create outgoing webhook for oracle', { + error, + abuseEntityId: abuseEntity.id, + }); + + await this.handleAbuseError(abuseEntity); + continue; + } + } + + await this.abuseSlackBot.sendAbuseNotification({ + abuseId: abuseEntity.id, + chainId: abuseEntity.chainId, + escrowAddress: abuseEntity.escrowAddress, + manifestUrl: escrow.manifestUrl as string, + }); + abuseEntity.status = AbuseStatus.NOTIFIED; + await this.abuseRepository.updateOne(abuseEntity); + } catch (err) { + this.logger.error(`Error sending abuse: ${err.message}`); + await this.handleAbuseError(abuseEntity); + } + } + } + + async processClassifiedAbuses(): Promise { + const abuseEntities = await this.abuseRepository.findClassified({ + relations: { user: true }, + }); + + for (const abuseEntity of abuseEntities) { + try { + const { chainId, escrowAddress } = abuseEntity; + const escrow = await EscrowUtils.getEscrow( + abuseEntity.chainId, + abuseEntity.escrowAddress, + ); + + if (abuseEntity.decision === AbuseDecision.ACCEPTED) { + await this.slashAccount({ + slasher: abuseEntity?.user?.evmAddress as string, + staker: escrow.launcher, + chainId: abuseEntity.chainId, + escrowAddress: abuseEntity.escrowAddress, + amount: Number(abuseEntity.amount), + }); + const webhookUrl = ( + await OperatorUtils.getOperator(chainId, escrow.launcher) + ).webhookUrl as string; + const webhookPayload = { + chainId, + escrowAddress, + eventType: EventType.ABUSE_DETECTED, + }; + + try { + await this.webhookOutgoingService.createOutgoingWebhook( + webhookPayload, + webhookUrl, + ); + } catch (error) { + if (!isDuplicatedError(error)) { + this.logger.error( + 'Failed to create outgoing webhook for oracle', + { + error, + abuseEntityId: abuseEntity.id, + }, + ); + + await this.handleAbuseError(abuseEntity); + continue; + } + } + } else { + await this.reputationService.decreaseReputation( + chainId, + abuseEntity.user?.evmAddress as string, + ReputationEntityType.WORKER, + ); + const webhookPayload = { + chainId: chainId, + escrowAddress: escrowAddress, + eventType: EventType.ABUSE_DISMISSED, + }; + const webhookUrl = ( + await OperatorUtils.getOperator( + chainId, + escrow.exchangeOracle as string, + ) + ).webhookUrl as string; + + try { + await this.webhookOutgoingService.createOutgoingWebhook( + webhookPayload, + webhookUrl, + ); + } catch (error) { + if (!isDuplicatedError(error)) { + this.logger.error( + 'Failed to create outgoing webhook for oracle', + { + error, + abuseEntityId: abuseEntity.id, + }, + ); + + await this.handleAbuseError(abuseEntity); + continue; + } + } + } + abuseEntity.status = AbuseStatus.COMPLETED; + await this.abuseRepository.updateOne(abuseEntity); + } catch (err) { + this.logger.error(`Error sending abuse: ${err.message}`); + await this.handleAbuseError(abuseEntity); + } + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-auth.guard.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-auth.guard.ts new file mode 100644 index 0000000000..324296dbdd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-auth.guard.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { SlackAuthGuard } from '../../common/guards/slack.auth'; +import { SlackConfigService } from '../../config/slack-config.service'; + +@Injectable() +export class AbuseSlackAuthGuard extends SlackAuthGuard { + constructor(slackConfigService: SlackConfigService) { + super(slackConfigService.abuseSigningSecret); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts new file mode 100644 index 0000000000..07d989b415 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts @@ -0,0 +1,206 @@ +import { Test } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { SlackConfigService } from '../../config/slack-config.service'; +import { AbuseSlackBot } from './abuse.slack-bot'; +import { faker } from '@faker-js/faker'; +import { AbuseDecision } from './constants'; +import { createHttpServiceMock } from '../../../test/mock-creators/nest'; + +const mockHttpService = createHttpServiceMock(); + +describe('AbuseSlackBot', () => { + let abuseSlackBot: AbuseSlackBot; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + AbuseSlackBot, + { + provide: HttpService, + useValue: mockHttpService, + }, + { + provide: SlackConfigService, + useValue: { + abuseWebhookUrl: faker.internet.url(), + abuseOauthToken: faker.internet.jwt(), + }, + }, + ], + }).compile(); + + abuseSlackBot = moduleRef.get(AbuseSlackBot); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('sendAbuseNotification', () => { + let spyOnSendNotification: jest.SpyInstance; + + beforeAll(() => { + spyOnSendNotification = jest + .spyOn(abuseSlackBot, 'sendNotification') + .mockImplementation(); + }); + + afterAll(() => { + spyOnSendNotification.mockRestore(); + }); + + it('should send a notification with the correct payload', async () => { + const abuseId = faker.number.int(); + const chainId = faker.number.int(); + const escrowAddress = faker.finance.ethereumAddress(); + const manifestUrl = faker.internet.url(); + + await expect( + abuseSlackBot.sendAbuseNotification({ + abuseId, + chainId, + escrowAddress, + manifestUrl, + }), + ).resolves.not.toThrow(); + + expect(spyOnSendNotification).toHaveBeenCalledWith({ + text: 'New abuse report received!', + attachments: [ + { + title: 'Escrow', + fields: [ + { title: 'Address', value: escrowAddress }, + { title: 'ChainId', value: chainId.toString() }, + { title: 'Manifest', value: manifestUrl }, + ], + }, + { + fallback: 'Actions', + title: 'Actions', + callback_id: abuseId.toString(), + color: '#3AA3E3', + actions: [ + { + name: 'accept', + text: 'Slash', + type: 'button', + style: 'primary', + value: AbuseDecision.ACCEPTED, + }, + { + name: 'reject', + text: 'Reject', + type: 'button', + style: 'danger', + value: AbuseDecision.REJECTED, + confirm: { + title: 'Cancel abuse', + text: `Are you sure you want to cancel slash for escrow ${escrowAddress}?`, + ok_text: 'Yes', + dismiss_text: 'No', + }, + }, + ], + }, + ], + }); + }); + + it('should throw an error if sending the notification fails', async () => { + spyOnSendNotification.mockRejectedValueOnce( + new Error('Error sending Slack notification'), + ); + + await expect( + abuseSlackBot.sendAbuseNotification({ + abuseId: faker.number.int(), + chainId: faker.number.int(), + escrowAddress: faker.finance.ethereumAddress(), + manifestUrl: faker.internet.url(), + }), + ).rejects.toThrow('Error sending Slack notification'); + }); + }); + + describe('triggerAbuseReportModal', () => { + let spyOnOpenModal: jest.SpyInstance; + + beforeAll(() => { + spyOnOpenModal = jest + .spyOn(abuseSlackBot, 'openModal') + .mockImplementation(); + }); + + afterAll(() => { + spyOnOpenModal.mockRestore(); + }); + + it('should open a modal with the correct payload', async () => { + const abuseId = faker.number.int(); + const chainId = faker.number.int(); + const escrowAddress = faker.finance.ethereumAddress(); + const triggerId = faker.string.uuid(); + const responseUrl = faker.internet.url(); + const maxAmount = faker.number.int({ min: 1, max: 1000 }); + + await expect( + abuseSlackBot.triggerAbuseReportModal({ + abuseId, + chainId, + escrowAddress, + maxAmount, + triggerId, + responseUrl, + }), + ).resolves.not.toThrow(); + + expect(spyOnOpenModal).toHaveBeenCalledWith(triggerId, { + type: 'modal', + callback_id: `${abuseId}`, + title: { type: 'plain_text', text: 'Confirm slash' }, + private_metadata: JSON.stringify({ responseUrl }), + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: `Max amount: ${maxAmount}` }, + }, + { + type: 'input', + block_id: 'quantity_input', + element: { + action_id: 'quantity', + type: 'number_input', + is_decimal_allowed: true, + min_value: '0', + max_value: maxAmount.toString(), + }, + label: { + type: 'plain_text', + text: 'Please enter the quantity (in HMT):', + }, + }, + ], + submit: { type: 'plain_text', text: 'Submit' }, + close: { type: 'plain_text', text: 'Cancel' }, + }); + }); + + it('should throw an error if opening the modal fails', async () => { + spyOnOpenModal.mockRejectedValueOnce( + new Error('Error opening Slack modal'), + ); + + await expect( + abuseSlackBot.triggerAbuseReportModal({ + abuseId: faker.number.int(), + chainId: faker.number.int(), + escrowAddress: faker.finance.ethereumAddress(), + maxAmount: faker.number.int(), + triggerId: faker.string.uuid(), + responseUrl: faker.internet.url(), + }), + ).rejects.toThrow('Error opening Slack modal'); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.ts new file mode 100644 index 0000000000..d829278b8e --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.ts @@ -0,0 +1,113 @@ +import { ChainId } from '@human-protocol/sdk'; +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { SlackConfigService } from '../../config/slack-config.service'; +import { SlackBotApp } from '../../integrations/slack-bot-app/slack-bot-app'; +import { AbuseDecision } from './constants'; +import { View } from '@slack/web-api'; +import { IncomingWebhookSendArguments } from '@slack/webhook'; + +@Injectable() +export class AbuseSlackBot extends SlackBotApp { + constructor( + httpService: HttpService, + slackConfigService: SlackConfigService, + ) { + super(httpService, { + webhookUrl: slackConfigService.abuseWebhookUrl, + oauthToken: slackConfigService.abuseOauthToken, + }); + } + + async sendAbuseNotification(data: { + abuseId: number; + chainId: ChainId; + escrowAddress: string; + manifestUrl: string; + }): Promise { + const message: IncomingWebhookSendArguments = { + text: 'New abuse report received!', + attachments: [ + { + title: 'Escrow', + fields: [ + { title: 'Address', value: data.escrowAddress }, + { title: 'ChainId', value: data.chainId.toString() }, + { title: 'Manifest', value: data.manifestUrl }, + ], + }, + { + fallback: 'Actions', + title: 'Actions', + callback_id: data.abuseId.toString(), + color: '#3AA3E3', + actions: [ + { + name: 'accept', + text: 'Slash', + type: 'button', + style: 'primary', + value: AbuseDecision.ACCEPTED, + }, + { + name: 'reject', + text: 'Reject', + type: 'button', + style: 'danger', + value: AbuseDecision.REJECTED, + confirm: { + title: 'Cancel abuse', + text: `Are you sure you want to cancel slash for escrow ${data.escrowAddress}?`, + ok_text: 'Yes', + dismiss_text: 'No', + }, + }, + ], + }, + ], + }; + + await this.sendNotification(message); + } + + async triggerAbuseReportModal(data: { + abuseId: number; + chainId: ChainId; + escrowAddress: string; + maxAmount: number; + triggerId: string; + responseUrl: string; + }): Promise { + const modalView: View = { + type: 'modal', + callback_id: `${data.abuseId}`, + title: { type: 'plain_text', text: 'Confirm slash' }, + private_metadata: JSON.stringify({ responseUrl: data.responseUrl }), + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: `Max amount: ${data.maxAmount}` }, + }, + { + type: 'input', + block_id: 'quantity_input', + element: { + action_id: 'quantity', + type: 'number_input', + is_decimal_allowed: true, + min_value: '0', + max_value: data.maxAmount.toString(), + }, + label: { + type: 'plain_text', + text: 'Please enter the quantity (in HMT):', + }, + }, + ], + submit: { type: 'plain_text', text: 'Submit' }, + close: { type: 'plain_text', text: 'Cancel' }, + }; + + await this.openModal(data.triggerId, modalView); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/constants.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/constants.ts new file mode 100644 index 0000000000..c42b92c447 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/constants.ts @@ -0,0 +1,11 @@ +export enum AbuseStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + NOTIFIED = 'notified', +} + +export enum AbuseDecision { + REJECTED = 'rejected', + ACCEPTED = 'accepted', +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/fixtures.ts new file mode 100644 index 0000000000..d4336027e6 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/fixtures.ts @@ -0,0 +1,29 @@ +import { faker } from '@faker-js/faker'; +import { generateTestnetChainId } from '../web3/fixtures'; +import { AbuseStatus } from './constants'; +import { generateWorkerUser } from '../user/fixtures'; +import { AbuseEntity } from './abuse.entity'; + +export function generateAbuseEntity( + overrides?: Partial, +): AbuseEntity { + const user = overrides?.user || generateWorkerUser(); + + const abuse: AbuseEntity = { + id: faker.number.int(), + escrowAddress: faker.finance.ethereumAddress(), + chainId: generateTestnetChainId(), + userId: user.id, + user: user, + retriesCount: faker.number.int({ min: 0, max: 4 }), + status: AbuseStatus.PENDING, + decision: null, + amount: null, + waitUntil: faker.date.future(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + ...overrides, + }; + + return abuse; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/types.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/types.ts new file mode 100644 index 0000000000..9e733cea43 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/types.ts @@ -0,0 +1,83 @@ +import { ChainId } from '@human-protocol/sdk'; + +export type ReportAbuseInput = { + userId: number; + chainId: ChainId; + escrowAddress: string; +}; + +export type SlackInteractionBase = { + type: string; + team: { + id: string; + domain: string; + }; + user: { + id: string; + username: string; + name: string; + team_id: string; + }; + api_app_id: string; + token: string; + trigger_id: string; + is_enterprise_install: boolean; + enterprise: null | object; +}; + +export type InteractiveMessage = SlackInteractionBase & { + type: 'interactive_message'; + actions: { + name: string; + type: string; + value: string; + }[]; + callback_id: string; + channel: { + id: string; + name: string; + }; + action_ts: string; + message_ts: string; + attachment_id: string; + original_message: { + subtype: string; + text: string; + attachments: object[]; + type: string; + }; + response_url: string; + trigger_id: string; +}; + +export type ViewSubmission = SlackInteractionBase & { + type: 'view_submission'; + view: { + callback_id: string; + private_metadata: string; + state: { + values: { + [blockId: string]: { + [actionId: string]: { + type: string; + value: number; + }; + }; + }; + }; + }; +}; + +export type SlackInteraction = InteractiveMessage | ViewSubmission; + +export function isInteractiveMessage( + data: SlackInteraction, +): data is InteractiveMessage { + return data.type === 'interactive_message'; +} + +export function isViewSubmission( + data: SlackInteraction, +): data is ViewSubmission { + return data.type === 'view_submission'; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index 9ae39fe8b6..9c2b8b23b0 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -39,8 +39,8 @@ const { publicKey, privateKey } = generateES256Keys(); const mockAuthConfigService = { jwtPrivateKey: privateKey, jwtPublicKey: publicKey, - accessTokenExpiresIn: 600000, - refreshTokenExpiresIn: 3600000, + accessTokenExpiresIn: 600, + refreshTokenExpiresIn: 3600, verifyEmailTokenExpiresIn: 86400000, forgotPasswordExpiresIn: 86400000, humanAppEmail: faker.internet.email(), @@ -608,7 +608,7 @@ describe('AuthService', () => { reputation_network: mockWeb3ConfigService.operatorAddress, qualifications: user.userQualifications ? user.userQualifications.map( - (userQualification) => userQualification.qualification.reference, + (userQualification) => userQualification.qualification?.reference, ) : [], }; @@ -728,7 +728,7 @@ describe('AuthService', () => { qualifications: user.userQualifications ? user.userQualifications.map( (userQualification) => - userQualification.qualification.reference, + userQualification.qualification?.reference, ) : [], }; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index eeeaa4dd80..3cf9243793 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -8,12 +8,12 @@ import { NDAConfigService } from '../../config/nda-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import logger from '../../logger'; -import * as web3Utils from '../../utils/web3'; +import * as httpUtils from '../../utils/http'; import * as securityUtils from '../../utils/security'; +import * as web3Utils from '../../utils/web3'; import { EmailAction } from '../email/constants'; import { EmailService } from '../email/email.service'; -import { StorageService } from '../storage/storage.service'; import { OperatorStatus, SiteKeyRepository, @@ -142,7 +142,7 @@ export class AuthService { try { url = await KVStoreUtils.get(chainId, address, KVStoreKeys.url); } catch (noop) {} - if (!url || !StorageService.isValidUrl(url)) { + if (!url || !httpUtils.isValidHttpUrl(url)) { throw new InvalidOperatorUrlError(url); } @@ -230,7 +230,7 @@ export class AuthService { reputation_network: this.web3ConfigService.operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( - (userQualification) => userQualification.qualification.reference, + (userQualification) => userQualification.qualification?.reference, ) : [], site_key: hCaptchaSiteKey, diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts index 2459f13b41..75f4042c18 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts @@ -6,12 +6,14 @@ import { WebhookOutgoingModule } from '../webhook/webhook-outgoing.module'; import { CronJobService } from './cron-job.service'; import { CronJobRepository } from './cron-job.repository'; +import { AbuseModule } from '../abuse/abuse.module'; @Module({ imports: [ WebhookIncomingModule, WebhookOutgoingModule, EscrowCompletionModule, + AbuseModule, ], providers: [CronJobService, CronJobRepository], }) diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts index e39cb0f1ee..bbeff8b38c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts @@ -6,6 +6,7 @@ import { CronJobType } from '../../common/enums/cron-job'; import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; import { WebhookIncomingService } from '../webhook/webhook-incoming.service'; import { EscrowCompletionService } from '../escrow-completion/escrow-completion.service'; +import { AbuseService } from '../abuse/abuse.service'; describe('CronJobService', () => { let service: CronJobService; @@ -13,6 +14,7 @@ describe('CronJobService', () => { let webhookIncomingService: jest.Mocked; let webhookOutgoingService: jest.Mocked; let escrowCompletionService: jest.Mocked; + let abuseService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -46,6 +48,13 @@ describe('CronJobService', () => { processAwaitingPayouts: jest.fn(), }, }, + { + provide: AbuseService, + useValue: { + processAbuseRequests: jest.fn(), + processClassifiedAbuses: jest.fn(), + }, + }, ], }).compile(); @@ -54,6 +63,7 @@ describe('CronJobService', () => { webhookIncomingService = module.get(WebhookIncomingService); webhookOutgoingService = module.get(WebhookOutgoingService); escrowCompletionService = module.get(EscrowCompletionService); + abuseService = module.get(AbuseService); }); describe('startCronJob', () => { @@ -431,4 +441,96 @@ describe('CronJobService', () => { expect(service.completeCronJob).toHaveBeenCalled(); }); }); + + describe('processClassifiedAbuses', () => { + it('should skip processing if a cron job is already running', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(true); + + await service.processClassifiedAbuses(); + + expect(abuseService.processClassifiedAbuses).not.toHaveBeenCalled(); + }); + + it('should process classified abuses and complete the cron job', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + jest + .spyOn(service, 'startCronJob') + .mockResolvedValue(new CronJobEntity()); + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValue(new CronJobEntity()); + + await service.processClassifiedAbuses(); + + expect(abuseService.processClassifiedAbuses).toHaveBeenCalled(); + expect(service.startCronJob).toHaveBeenCalledWith( + CronJobType.ProcessClassifiedAbuse, + ); + expect(service.completeCronJob).toHaveBeenCalled(); + }); + + it('should complete the cron job even if processing fails', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + jest + .spyOn(service, 'startCronJob') + .mockResolvedValue(new CronJobEntity()); + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValue(new CronJobEntity()); + + abuseService.processClassifiedAbuses.mockRejectedValue( + new Error('Processing error'), + ); + + await service.processClassifiedAbuses(); + + expect(service.completeCronJob).toHaveBeenCalled(); + }); + }); + + describe('processAbuseRequests', () => { + it('should skip processing if a cron job is already running', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(true); + + await service.processAbuseRequests(); + + expect(abuseService.processAbuseRequests).not.toHaveBeenCalled(); + }); + + it('should process abuse requests and complete the cron job', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + jest + .spyOn(service, 'startCronJob') + .mockResolvedValue(new CronJobEntity()); + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValue(new CronJobEntity()); + + await service.processAbuseRequests(); + + expect(abuseService.processAbuseRequests).toHaveBeenCalled(); + expect(service.startCronJob).toHaveBeenCalledWith( + CronJobType.ProcessRequestedAbuse, + ); + expect(service.completeCronJob).toHaveBeenCalled(); + }); + + it('should complete the cron job even if processing fails', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + jest + .spyOn(service, 'startCronJob') + .mockResolvedValue(new CronJobEntity()); + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValue(new CronJobEntity()); + + abuseService.processAbuseRequests.mockRejectedValue( + new Error('Processing error'), + ); + + await service.processAbuseRequests(); + + expect(service.completeCronJob).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts index d391154a3f..8b48208b7b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -3,12 +3,13 @@ import { Cron } from '@nestjs/schedule'; import { CronJobType } from '../../common/enums/cron-job'; -import { CronJobEntity } from './cron-job.entity'; -import { CronJobRepository } from './cron-job.repository'; +import logger from '../../logger'; +import { AbuseService } from '../abuse/abuse.service'; +import { EscrowCompletionService } from '../escrow-completion/escrow-completion.service'; import { WebhookIncomingService } from '../webhook/webhook-incoming.service'; import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; -import { EscrowCompletionService } from '../escrow-completion/escrow-completion.service'; -import logger from '../../logger'; +import { CronJobEntity } from './cron-job.entity'; +import { CronJobRepository } from './cron-job.repository'; @Injectable() export class CronJobService { @@ -19,6 +20,7 @@ export class CronJobService { private readonly webhookIncomingService: WebhookIncomingService, private readonly webhookOutgoingService: WebhookOutgoingService, private readonly escrowCompletionService: EscrowCompletionService, + private readonly abuseService: AbuseService, ) {} /** @@ -219,4 +221,58 @@ export class CronJobService { this.logger.info('Awaiting payouts processing STOP'); await this.completeCronJob(cronJob); } + + /** + * Process a pending abuse request. + * @returns {Promise} - Returns a promise that resolves when the operation is complete. + */ + @Cron('*/2 * * * *') + public async processAbuseRequests(): Promise { + const isCronJobRunning = await this.isCronJobRunning( + CronJobType.ProcessRequestedAbuse, + ); + + if (isCronJobRunning) { + return; + } + + this.logger.info('Process Abuse START'); + const cronJob = await this.startCronJob(CronJobType.ProcessRequestedAbuse); + + try { + await this.abuseService.processAbuseRequests(); + } catch (e) { + this.logger.error('Error processing abuse requests', e); + } + + this.logger.info('Process Abuse STOP'); + await this.completeCronJob(cronJob); + } + + /** + * Process a classified abuse. + * @returns {Promise} - Returns a promise that resolves when the operation is complete. + */ + @Cron('*/2 * * * *') + public async processClassifiedAbuses(): Promise { + const isCronJobRunning = await this.isCronJobRunning( + CronJobType.ProcessClassifiedAbuse, + ); + + if (isCronJobRunning) { + return; + } + + this.logger.info('Process classified abuses START'); + const cronJob = await this.startCronJob(CronJobType.ProcessClassifiedAbuse); + + try { + await this.abuseService.processClassifiedAbuses(); + } catch (e) { + this.logger.error('Error processing classified abuse requests', e); + } + + this.logger.info('Process classified abuses STOP'); + await this.completeCronJob(cronJob); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts new file mode 100644 index 0000000000..8033e9a750 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { Web3Module } from '../web3/web3.module'; + +import { PgpEncryptionService } from './pgp-encryption.service'; + +@Module({ + imports: [Web3Module], + providers: [PgpEncryptionService], + exports: [PgpEncryptionService], +}) +export class EncryptionModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.spec.ts new file mode 100644 index 0000000000..b260663ec0 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.spec.ts @@ -0,0 +1,245 @@ +jest.mock('@human-protocol/sdk', () => { + const actualSdk = jest.requireActual('@human-protocol/sdk'); + const mockedSdk = jest.createMockFromModule< + typeof import('@human-protocol/sdk') + >('@human-protocol/sdk'); + + return { + ...actualSdk, + KVStoreUtils: mockedSdk.KVStoreUtils, + }; +}); + +import { faker } from '@faker-js/faker'; +import { Encryption, EncryptionUtils, KVStoreUtils } from '@human-protocol/sdk'; +import { Test } from '@nestjs/testing'; + +import { PGPConfigService } from '../../config/pgp-config.service'; +import { Web3ConfigService } from '../../config/web3-config.service'; +import { + generateTestnetChainId, + mockWeb3ConfigService, +} from '../web3/fixtures'; +import { Web3Service } from '../web3/web3.service'; + +import { PgpEncryptionService } from './pgp-encryption.service'; + +const mockedKVStoreUtils = jest.mocked(KVStoreUtils); + +describe('PgpEncryptionService', () => { + let mockPgpPublicKey: string; + let mockPgpConfigService: Omit; + let pgpEncryptionService: PgpEncryptionService; + + beforeAll(async () => { + const pgpPassphrase = faker.internet.password(); + const pgpKeyPairData = await EncryptionUtils.generateKeyPair( + faker.string.sample(), + faker.internet.email(), + pgpPassphrase, + ); + mockPgpPublicKey = pgpKeyPairData.publicKey; + mockPgpConfigService = { + encrypt: true, + privateKey: pgpKeyPairData.privateKey, + passphrase: pgpKeyPairData.passphrase, + }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: Web3ConfigService, + useValue: mockWeb3ConfigService, + }, + Web3Service, + { + provide: PGPConfigService, + useValue: mockPgpConfigService, + }, + PgpEncryptionService, + ], + }).compile(); + + pgpEncryptionService = + moduleRef.get(PgpEncryptionService); + + await pgpEncryptionService.onModuleInit(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('decrypt', () => { + it('should decrypt data that encrypted for reputation oracle', async () => { + const data = faker.lorem.words(); + const encryptedData = await EncryptionUtils.encrypt(data, [ + mockPgpPublicKey, + ]); + + const decryptedData = await pgpEncryptionService.maybeDecryptFile( + Buffer.from(encryptedData), + ); + + expect(decryptedData.toString()).toEqual(data); + }); + }); + + describe('maybeDecryptFile', () => { + it('should return data "as is" if not encrypted', async () => { + const data = Buffer.from(faker.lorem.words()); + + const result = await pgpEncryptionService.maybeDecryptFile(data); + + expect(result).toEqual(data); + }); + + it('should return decrypted data if encrypted for Reputation Oracle', async () => { + const data = faker.lorem.words(); + const encryptedData = await EncryptionUtils.encrypt(data, [ + mockPgpPublicKey, + ]); + + const decryptedData = await pgpEncryptionService.maybeDecryptFile( + Buffer.from(encryptedData), + ); + + expect(decryptedData.toString()).toEqual(data); + }); + }); + + describe('encrypt', () => { + describe('when encryption disabled via config', () => { + let originalConfigValue: boolean; + + beforeAll(() => { + originalConfigValue = mockPgpConfigService.encrypt; + (mockPgpConfigService as any).encrypt = false; + }); + + afterAll(() => { + (mockPgpConfigService as any).encrypt = originalConfigValue; + }); + + it('should not encrypt content', async () => { + const chainId = generateTestnetChainId(); + const content = faker.lorem.words(); + + const result = await pgpEncryptionService.encrypt( + Buffer.from(content), + chainId, + ); + + expect(result).toEqual(content); + }); + }); + + describe('when encryption enabled via config', () => { + const EXPECTED_PGP_PUBLIC_KEY_ERROR_MESSAGE = + 'Failed to get PGP public key for oracle'; + + let originalConfigValue: boolean; + + beforeAll(() => { + originalConfigValue = mockPgpConfigService.encrypt; + (mockPgpConfigService as any).encrypt = true; + }); + + afterAll(() => { + (mockPgpConfigService as any).encrypt = originalConfigValue; + }); + + it('should encrypt with reputation oracle public key as default', async () => { + mockedKVStoreUtils.getPublicKey.mockImplementation( + async (_chainId, address) => { + if (address === mockWeb3ConfigService.operatorAddress) { + return mockPgpPublicKey; + } + return ''; + }, + ); + + const chainId = generateTestnetChainId(); + const content = faker.lorem.words(); + + const encryptedContent = await pgpEncryptionService.encrypt( + Buffer.from(content), + chainId, + ); + expect(EncryptionUtils.isEncrypted(encryptedContent)).toBe(true); + + const decryptedContent = + await pgpEncryptionService.decrypt(encryptedContent); + expect(decryptedContent.toString()).toEqual(content); + }); + + it('should throw if default public key is missing', async () => { + const chainId = generateTestnetChainId(); + const content = faker.lorem.words(); + + await expect( + pgpEncryptionService.encrypt(Buffer.from(content), chainId), + ).rejects.toThrow(EXPECTED_PGP_PUBLIC_KEY_ERROR_MESSAGE); + }); + + it('should throw if failing to get default public key', async () => { + mockedKVStoreUtils.getPublicKey.mockRejectedValueOnce( + new Error('Ooops'), + ); + + const chainId = generateTestnetChainId(); + const content = faker.lorem.words(); + + await expect( + pgpEncryptionService.encrypt(Buffer.from(content), chainId), + ).rejects.toThrow(EXPECTED_PGP_PUBLIC_KEY_ERROR_MESSAGE); + }); + + it('should encrypt for provided oracle and default reputation oracle', async () => { + const pgpPassphrase = faker.internet.password(); + const pgpKeyPairData = await EncryptionUtils.generateKeyPair( + faker.string.sample(), + faker.internet.email(), + pgpPassphrase, + ); + const otherOracleAddress = faker.finance.ethereumAddress(); + + mockedKVStoreUtils.getPublicKey.mockImplementation( + async (_chainId, address) => { + if (address === otherOracleAddress) { + return pgpKeyPairData.publicKey; + } + if (address === mockWeb3ConfigService.operatorAddress) { + return mockPgpPublicKey; + } + return ''; + }, + ); + + const chainId = generateTestnetChainId(); + const content = faker.lorem.words(); + + const encryptedContent = await pgpEncryptionService.encrypt( + Buffer.from(content), + chainId, + [otherOracleAddress], + ); + expect(EncryptionUtils.isEncrypted(encryptedContent)).toBe(true); + + const repOracleDecryptedContent = + await pgpEncryptionService.decrypt(encryptedContent); + expect(repOracleDecryptedContent.toString()).toEqual(content); + + const encryptionSdk = await Encryption.build( + pgpKeyPairData.privateKey, + pgpKeyPairData.passphrase, + ); + const otherOracleDecryptedContent = + await encryptionSdk.decrypt(encryptedContent); + expect(Buffer.from(otherOracleDecryptedContent).toString()).toEqual( + content, + ); + }); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.ts new file mode 100644 index 0000000000..c533cb5797 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/pgp-encryption.service.ts @@ -0,0 +1,110 @@ +import { + ChainId, + Encryption, + EncryptionUtils, + KVStoreUtils, +} from '@human-protocol/sdk'; +import { Injectable, OnModuleInit } from '@nestjs/common'; + +import { PGPConfigService } from '../../config/pgp-config.service'; +import logger from '../../logger'; + +import { Web3Service } from '../web3/web3.service'; + +@Injectable() +export class PgpEncryptionService implements OnModuleInit { + private readonly logger = logger.child({ + context: PgpEncryptionService.name, + }); + + private sdkInstance: Encryption; + + constructor( + private readonly pgpConfigService: PGPConfigService, + private readonly web3Service: Web3Service, + ) {} + + async onModuleInit(): Promise { + this.sdkInstance = await Encryption.build( + this.pgpConfigService.privateKey, + this.pgpConfigService.passphrase, + ); + } + + /** + * Checks if file content is PGP-encrypted and decrypts it + * using Reputation Oracle PGP key, otherwise returns as is. + */ + async maybeDecryptFile(fileContent: Buffer): Promise { + const contentAsString = fileContent.toString(); + if (!EncryptionUtils.isEncrypted(contentAsString)) { + return fileContent; + } + + return this.decrypt(contentAsString); + } + + /** + * Expects some content encrypted with Reputation Oracle PGP public key + * in PGP message format and decrypts it + */ + async decrypt(content: string): Promise { + const decryptedData = await this.sdkInstance.decrypt(content); + + return Buffer.from(decryptedData); + } + + /** + * Encrypts content using PGP public keys of provided oracles. + * Always uses Reputation Oracle key in addition to provided list. + */ + async encrypt( + content: string | Buffer, + chainId: ChainId, + oracleAddresses: string[] = [], + ): Promise { + if (!this.pgpConfigService.encrypt) { + return content.toString(); + } + + const addresses = Array.from( + new Set([ + ...oracleAddresses, + this.web3Service.getSigner(chainId).address, + ]), + ); + + const publicKeys = await Promise.all( + addresses.map((address) => + this.getPgpPublicKeyForOracle(chainId, address), + ), + ); + + return EncryptionUtils.encrypt(content, publicKeys); + } + + private async getPgpPublicKeyForOracle( + chainId: ChainId, + oracleAddress: string, + ): Promise { + try { + const pgpPublicKey = await KVStoreUtils.getPublicKey( + chainId, + oracleAddress, + ); + if (!pgpPublicKey) { + throw new Error('Public key is missing'); + } + + return pgpPublicKey; + } catch (error) { + const errorMessage = 'Failed to get PGP public key for oracle'; + this.logger.error(errorMessage, { + chainId, + oracleAddress, + detail: error.message, + }); + throw new Error(errorMessage); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts index c1053af5eb..87a6140aa8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts @@ -33,6 +33,7 @@ import { ReputationConfigService } from '../../config/reputation-config.service' import { PGPConfigService } from '../../config/pgp-config.service'; import { S3ConfigService } from '../../config/s3-config.service'; import { EscrowPayoutsBatchRepository } from './escrow-payouts-batch.repository'; +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -159,15 +160,19 @@ describe('escrowCompletionService', () => { provide: Web3Service, useValue: mockWeb3Service, }, + { + provide: StorageService, + useValue: createMock(), + }, WebhookOutgoingService, PayoutService, ReputationService, Web3ConfigService, ServerConfigService, - StorageService, ReputationConfigService, S3ConfigService, PGPConfigService, + PgpEncryptionService, { provide: HttpService, useValue: createMock() }, ], }).compile(); diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts index f015e99afd..48ee4dde8f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts @@ -6,9 +6,10 @@ import { catchError, firstValueFrom } from 'rxjs'; import { KycConfigService } from '../../config/kyc-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import logger from '../../logger'; +import * as httpUtils from '../../utils/http'; import { UserEntity } from '../user'; -import { formatAxiosError } from '../../utils/format-axios-error'; import { Web3Service } from '../web3/web3.service'; + import { KycStatus } from './constants'; import { KycSignedAddressDto, UpdateKycStatusDto } from './kyc.dto'; import { KycEntity } from './kyc.entity'; @@ -76,7 +77,7 @@ export class KycService { ) .pipe( catchError((error: AxiosError) => { - const formattedError = formatAxiosError(error); + const formattedError = httpUtils.formatAxiosError(error); const errorMessage = 'Error occurred while initializing KYC session'; this.logger.error(errorMessage, { diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.module.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.module.ts index c079095406..c770aeb57c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { EncryptionModule } from '../encryption/encryption.module'; import { ReputationModule } from '../reputation/reputation.module'; import { Web3Module } from '../web3/web3.module'; import { StorageModule } from '../storage/storage.module'; @@ -7,7 +8,7 @@ import { StorageModule } from '../storage/storage.module'; import { PayoutService } from './payout.service'; @Module({ - imports: [ReputationModule, Web3Module, StorageModule], + imports: [EncryptionModule, ReputationModule, Web3Module, StorageModule], providers: [PayoutService], exports: [PayoutService], }) diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts index d8b6925093..5cc014da40 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts @@ -1,5 +1,4 @@ import { createMock } from '@golevelup/ts-jest'; -import { ConfigModule, registerAs } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { ChainId, EscrowClient } from '@human-protocol/sdk'; import { @@ -8,12 +7,6 @@ import { MOCK_FILE_URL, MOCK_REQUESTER_DESCRIPTION, MOCK_REQUESTER_TITLE, - MOCK_S3_ACCESS_KEY, - MOCK_S3_BUCKET, - MOCK_S3_ENDPOINT, - MOCK_S3_PORT, - MOCK_S3_SECRET_KEY, - MOCK_S3_USE_SSL, } from '../../../test/constants'; import { JobRequestType } from '../../common/enums'; import { Web3Service } from '../web3/web3.service'; @@ -23,6 +16,8 @@ import { CvatManifest } from '../../common/interfaces/manifest'; import { CvatAnnotationMeta } from '../../common/interfaces/job-result'; import { CalculatedPayout, SaveResultDto } from './payout.interface'; import { MissingManifestUrlError } from '../../common/errors/manifest'; +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; +import * as httpUtils from '../../utils/http'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -45,18 +40,6 @@ describe('PayoutService', () => { beforeEach(async () => { const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forFeature( - registerAs('s3', () => ({ - accessKey: MOCK_S3_ACCESS_KEY, - secretKey: MOCK_S3_SECRET_KEY, - endPoint: MOCK_S3_ENDPOINT, - port: MOCK_S3_PORT, - useSSL: MOCK_S3_USE_SSL, - bucket: MOCK_S3_BUCKET, - })), - ), - ], providers: [ { provide: Web3Service, @@ -65,6 +48,10 @@ describe('PayoutService', () => { }, }, { provide: StorageService, useValue: createMock() }, + { + provide: PgpEncryptionService, + useValue: createMock(), + }, PayoutService, ], }).compile(); @@ -342,7 +329,7 @@ describe('PayoutService', () => { .spyOn(storageService, 'downloadJsonLikeData') .mockResolvedValue(intermediateResults); - jest.spyOn(storageService, 'uploadJobSolutions').mockResolvedValue({ + jest.spyOn(payoutService, 'uploadJobResults').mockResolvedValue({ url: MOCK_FILE_URL, hash: MOCK_FILE_HASH, }); @@ -469,13 +456,12 @@ describe('PayoutService', () => { ], }; - jest.spyOn(storageService, 'copyFileFromURLToBucket').mockResolvedValue({ + jest.spyOn(httpUtils, 'downloadFile').mockResolvedValue(results); + + jest.spyOn(payoutService, 'uploadJobResults').mockResolvedValue({ url: MOCK_FILE_URL, hash: MOCK_FILE_HASH, }); - jest - .spyOn(storageService, 'downloadJsonLikeData') - .mockResolvedValue(results); const result = await payoutService.saveResultsCvat( chainId, diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts index d68d67e2c9..88ca58730f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts @@ -1,18 +1,22 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ChainId, EscrowClient } from '@human-protocol/sdk'; +import crypto from 'crypto'; +import { ethers } from 'ethers'; + import { AUDINO_RESULTS_ANNOTATIONS_FILENAME, AUDINO_VALIDATION_META_FILENAME, CVAT_RESULTS_ANNOTATIONS_FILENAME, CVAT_VALIDATION_META_FILENAME, } from '../../common/constants'; -import { ethers } from 'ethers'; +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; import { Web3Service } from '../web3/web3.service'; -import { JobRequestType } from '../../common/enums'; +import { ContentType, JobRequestType } from '../../common/enums'; import { StorageService } from '../storage/storage.service'; import { + JobManifest, AudinoManifest, CvatManifest, FortuneManifest, @@ -28,6 +32,7 @@ import { RequestAction, SaveResultDto, } from './payout.interface'; +import * as httpUtils from '../../utils/http'; import { getRequestType } from '../../utils/manifest'; import { assertValidJobRequestType } from '../../utils/type-guards'; import { MissingManifestUrlError } from '../../common/errors/manifest'; @@ -35,9 +40,9 @@ import { MissingManifestUrlError } from '../../common/errors/manifest'; @Injectable() export class PayoutService { constructor( - @Inject(StorageService) private readonly storageService: StorageService, private readonly web3Service: Web3Service, + private readonly pgpEncryptionService: PgpEncryptionService, ) {} /** @@ -61,7 +66,7 @@ export class PayoutService { } const manifest = - await this.storageService.downloadJsonLikeData(manifestUrl); + await this.storageService.downloadJsonLikeData(manifestUrl); const requestType = getRequestType(manifest).toLowerCase(); @@ -69,7 +74,7 @@ export class PayoutService { const { saveResults } = this.createPayoutSpecificActions[requestType]; - const results = await saveResults(chainId, escrowAddress, manifest); + const results = await saveResults(chainId, escrowAddress, manifest as any); return results; } @@ -98,7 +103,7 @@ export class PayoutService { } const manifest = - await this.storageService.downloadJsonLikeData(manifestUrl); + await this.storageService.downloadJsonLikeData(manifestUrl); const requestType = getRequestType(manifest).toLowerCase(); @@ -218,9 +223,9 @@ export class PayoutService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const intermediateResults = (await this.storageService.downloadJsonLikeData( - intermediateResultsUrl, - )) as FortuneFinalResult[]; + const intermediateResults = await this.storageService.downloadJsonLikeData< + FortuneFinalResult[] + >(intermediateResultsUrl); if (intermediateResults.length === 0) { throw new Error('No intermediate results found'); @@ -231,13 +236,14 @@ export class PayoutService { throw new Error('Not all required solutions have been sent'); } - const { url, hash } = await this.storageService.uploadJobSolutions( - escrowAddress, + return this.uploadJobResults( + JSON.stringify(intermediateResults), chainId, - intermediateResults, + escrowAddress, + { + extension: 'json', + }, ); - - return { url, hash }; } /** @@ -258,13 +264,44 @@ export class PayoutService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const { url, hash } = await this.storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, + let fileContent = await httpUtils.downloadFile( `${intermediateResultsUrl}/${CVAT_RESULTS_ANNOTATIONS_FILENAME}`, ); + fileContent = await this.pgpEncryptionService.maybeDecryptFile(fileContent); - return { url, hash }; + return this.uploadJobResults(fileContent, chainId, escrowAddress, { + prefix: 's3', + extension: 'zip', + }); + } + + /** + * Saves final results of a Audino-type job, using intermediate results for annotations. + * Retrieves intermediate results, copies files to storage, and returns the final results URL and hash. + * @param chainId The blockchain chain ID. + * @param escrowAddress The escrow contract address. + * @returns {Promise} The URL and hash for the saved results. + */ + public async saveResultsAudino( + chainId: ChainId, + escrowAddress: string, + ): Promise { + const signer = this.web3Service.getSigner(chainId); + + const escrowClient = await EscrowClient.build(signer); + + const intermediateResultsUrl = + await escrowClient.getIntermediateResultsUrl(escrowAddress); + + let fileContent = await httpUtils.downloadFile( + `${intermediateResultsUrl}/${AUDINO_RESULTS_ANNOTATIONS_FILENAME}`, + ); + fileContent = await this.pgpEncryptionService.maybeDecryptFile(fileContent); + + return this.uploadJobResults(fileContent, chainId, escrowAddress, { + prefix: 's3', + extension: 'zip', + }); } /** @@ -278,9 +315,10 @@ export class PayoutService { manifest: FortuneManifest, finalResultsUrl: string, ): Promise { - const finalResults = (await this.storageService.downloadJsonLikeData( - finalResultsUrl, - )) as FortuneFinalResult[]; + const finalResults = + await this.storageService.downloadJsonLikeData( + finalResultsUrl, + ); const recipients = finalResults .filter((result) => !result.error) @@ -316,8 +354,8 @@ export class PayoutService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const annotations: CvatAnnotationMeta = - await this.storageService.downloadJsonLikeData( + const annotations = + await this.storageService.downloadJsonLikeData( `${intermediateResultsUrl}/${CVAT_VALIDATION_META_FILENAME}`, ); @@ -384,8 +422,8 @@ export class PayoutService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const annotations: AudinoAnnotationMeta = - await this.storageService.downloadJsonLikeData( + const annotations = + await this.storageService.downloadJsonLikeData( `${intermediateResultsUrl}/${AUDINO_VALIDATION_META_FILENAME}`, ); @@ -429,27 +467,42 @@ export class PayoutService { } /** - * Saves final results of a Audino-type job, using intermediate results for annotations. - * Retrieves intermediate results, copies files to storage, and returns the final results URL and hash. - * @param chainId The blockchain chain ID. - * @param escrowAddress The escrow contract address. - * @returns {Promise} The URL and hash for the saved results. + * Encrypts results w/ JL and RepO PGP + * and uploads them to RepO storage. */ - public async saveResultsAudino( + public async uploadJobResults( + resultsData: string | Buffer, chainId: ChainId, escrowAddress: string, - ): Promise { + fileNameOptions: { + prefix?: string; + extension: string; + }, + ): Promise<{ url: string; hash: string }> { const signer = this.web3Service.getSigner(chainId); const escrowClient = await EscrowClient.build(signer); - const intermediateResultsUrl = - await escrowClient.getIntermediateResultsUrl(escrowAddress); + const jobLauncherAddress = + await escrowClient.getJobLauncherAddress(escrowAddress); - const { url, hash } = await this.storageService.copyFileFromURLToBucket( - escrowAddress, + const encryptedResults = await this.pgpEncryptionService.encrypt( + resultsData, chainId, - `${intermediateResultsUrl}/${AUDINO_RESULTS_ANNOTATIONS_FILENAME}`, + [jobLauncherAddress], + ); + const hash = crypto + .createHash('sha1') + .update(encryptedResults) + .digest('hex'); + + const prefix = fileNameOptions.prefix || ''; + const fileName = `${prefix}${hash}.${fileNameOptions.extension}`; + + const url = await this.storageService.uploadData( + encryptedResults, + fileName, + ContentType.PLAIN_TEXT, ); return { url, hash }; diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts index 9a375f641d..6a1d589059 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts @@ -1,33 +1,34 @@ import { + Body, Controller, - Post, - Get, Delete, - Body, - Param, - UseGuards, + Get, HttpCode, + Param, + Post, UseFilters, + UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, - ApiTags, + ApiBody, ApiOperation, ApiResponse, - ApiBody, + ApiTags, } from '@nestjs/swagger'; +import { Public, Roles } from '../../common/decorators'; +import { RolesAuthGuard } from '../../common/guards'; +import { UserRole } from '../user'; import { - CreateQualificationDto, AssignQualificationDto, + CreateQualificationDto, + QualificationResponseDto, UnassignQualificationDto, - QualificationDto, + UserQualificationOperationResponseDto, } from './qualification.dto'; import { QualificationErrorFilter } from './qualification.error.filter'; -import { RolesAuthGuard } from '../../common/guards'; import { QualificationService } from './qualification.service'; -import { Public, Roles } from '../../common/decorators'; -import { UserRole } from '../user'; @ApiTags('Qualification') @Controller('qualifications') @@ -40,23 +41,21 @@ export class QualificationController { @ApiResponse({ status: 201, description: 'Qualification created successfully', - type: QualificationDto, + type: QualificationResponseDto, }) @ApiBearerAuth() @UseGuards(RolesAuthGuard) @Roles([UserRole.ADMIN]) @Post() @HttpCode(201) - /** - * TODO: revisit DTO validation when - * refactoring business logic - */ async create( @Body() createQualificationDto: CreateQualificationDto, - ): Promise { - const qualification = await this.qualificationService.createQualification( - createQualificationDto, - ); + ): Promise { + const qualification = await this.qualificationService.createQualification({ + title: createQualificationDto.title, + description: createQualificationDto.description, + expiresAt: createQualificationDto.expiresAt, + }); return qualification; } @@ -64,16 +63,13 @@ export class QualificationController { @ApiResponse({ status: 200, description: 'List of qualifications', - type: QualificationDto, + type: QualificationResponseDto, isArray: true, }) @Public() @Get() @HttpCode(200) - async getQualifications(): Promise { - /** - * TODO: Refactor this endpoint to support pagination - */ + async getQualifications(): Promise { const qualifications = await this.qualificationService.getQualifications(); return qualifications; } @@ -95,7 +91,7 @@ export class QualificationController { async deleteQualification( @Param('reference') reference: string, ): Promise { - await this.qualificationService.delete(reference); + await this.qualificationService.deleteQualification(reference); } @ApiOperation({ summary: 'Assign a qualification to users' }) @@ -113,8 +109,8 @@ export class QualificationController { async assign( @Param('reference') reference: string, @Body() assignQualificationDto: AssignQualificationDto, - ): Promise { - await this.qualificationService.assign( + ): Promise { + return await this.qualificationService.assign( reference, assignQualificationDto.workerAddresses, ); @@ -135,8 +131,8 @@ export class QualificationController { async unassign( @Param('reference') reference: string, @Body() unassignQualificationDto: UnassignQualificationDto, - ): Promise { - await this.qualificationService.unassign( + ): Promise { + return await this.qualificationService.unassign( reference, unassignQualificationDto.workerAddresses, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.dto.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.dto.ts index 40f7d1371d..e6c27e0d4a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.dto.ts @@ -1,49 +1,74 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { - IsString, - IsOptional, - IsDateString, IsEthereumAddress, + IsOptional, + IsString, + MaxLength, + MinDate, } from 'class-validator'; -export class QualificationDto { +export class QualificationResponseDto { @ApiProperty() - public reference: string; + reference: string; + @ApiProperty() - public title: string; + title: string; + @ApiProperty() - public description: string; + description: string; + @ApiPropertyOptional({ name: 'expires_at' }) - public expiresAt?: string; + expiresAt?: string; } export class CreateQualificationDto { @ApiProperty() @IsString() - public reference: string; + @MaxLength(50) + title: string; @ApiProperty() @IsString() - public title: string; + @MaxLength(200) + description: string; + + @ApiPropertyOptional({ + name: 'expires_at', + example: '2025-04-09T15:30:00Z', + description: 'Expiration date in ISO 8601 format (must be a future date)', + format: 'date-time', + }) + @IsOptional() + @MinDate(new Date()) + @Type(() => Date) + expiresAt?: Date; +} + +class FailedUserQualificationOperation { + @ApiProperty({ name: 'evm_address' }) + evmAddress: string; @ApiProperty() - @IsString() - public description: string; + reason: string; +} - @ApiPropertyOptional({ name: 'expires_at' }) - @IsOptional() - @IsDateString() - public expiresAt?: string; +export class UserQualificationOperationResponseDto { + @ApiProperty() + success: string[]; + + @ApiProperty() + failed: FailedUserQualificationOperation[]; } export class AssignQualificationDto { @ApiProperty({ name: 'worker_addresses' }) @IsEthereumAddress({ each: true }) - public workerAddresses: string[]; + workerAddresses: string[]; } export class UnassignQualificationDto { @ApiProperty({ name: 'worker_addresses' }) @IsEthereumAddress({ each: true }) - public workerAddresses: string[]; + workerAddresses: string[]; } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts index 47239836b7..767c1b4b3a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts @@ -1,4 +1,5 @@ import { Column, Entity, Index, OneToMany } from 'typeorm'; + import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; import type { UserQualificationEntity } from './user-qualification.entity'; @@ -7,21 +8,21 @@ import type { UserQualificationEntity } from './user-qualification.entity'; @Index(['reference'], { unique: true }) export class QualificationEntity extends BaseEntity { @Column({ type: 'varchar', unique: true }) - public reference: string; + reference: string; - @Column({ type: 'text' }) - public title: string; + @Column({ type: 'varchar', length: 50 }) + title: string; - @Column({ type: 'text' }) - public description: string; + @Column({ type: 'varchar', length: 200 }) + description: string; - @Column({ type: 'timestamp', nullable: true }) - public expiresAt?: Date | null; + @Column({ type: 'timestamptz', nullable: true }) + expiresAt: Date | null; @OneToMany( 'UserQualificationEntity', (userQualification: UserQualificationEntity) => userQualification.qualification, ) - public userQualifications: UserQualificationEntity[]; + userQualifications?: UserQualificationEntity[]; } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.filter.ts index 051299e0f3..a8a0599270 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.filter.ts @@ -1,16 +1,16 @@ import { - ExceptionFilter, - Catch, ArgumentsHost, + Catch, + ExceptionFilter, HttpStatus, } from '@nestjs/common'; import { Request, Response } from 'express'; +import logger from '../../logger'; import { QualificationError, QualificationErrorMessage, } from './qualification.error'; -import logger from '../../logger'; @Catch(QualificationError) export class QualificationErrorFilter implements ExceptionFilter { diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts index d6fab67aeb..08c8bcb797 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts @@ -1,16 +1,18 @@ import { BaseError } from '../../common/errors/base'; export enum QualificationErrorMessage { - INVALID_EXPIRATION_TIME = 'Qualification should be valid for at least %minValidity% day(s)', + INVALID_EXPIRATION_TIME = 'Qualification should be valid till at least %minExpirationDate%', NOT_FOUND = 'Qualification not found', NO_WORKERS_FOUND = 'Workers not found', CANNOT_DETELE_ASSIGNED_QUALIFICATION = 'Cannot delete qualification because it is assigned to users', } export class QualificationError extends BaseError { - reference: string; - constructor(message: QualificationErrorMessage, reference: string) { + reference?: string; + constructor(message: QualificationErrorMessage, reference?: string) { super(message); - this.reference = reference; + if (reference) { + this.reference = reference; + } } } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts index 75c2aa1ed1..f70575cf4a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts @@ -1,14 +1,18 @@ import { Module } from '@nestjs/common'; import { UserModule } from '../user'; - -import { QualificationService } from './qualification.service'; -import { QualificationRepository } from './qualification.repository'; import { QualificationController } from './qualification.controller'; +import { QualificationRepository } from './qualification.repository'; +import { QualificationService } from './qualification.service'; +import { UserQualificationRepository } from './user-qualification.repository'; @Module({ imports: [UserModule], - providers: [QualificationService, QualificationRepository], + providers: [ + QualificationService, + QualificationRepository, + UserQualificationRepository, + ], controllers: [QualificationController], }) export class QualificationModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts index 5ef3464f0b..c9dcd5e016 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts @@ -1,64 +1,34 @@ import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, IsNull, MoreThan } from 'typeorm'; + import { BaseRepository } from '../../database/base.repository'; -import { DataSource, In, IsNull, MoreThan } from 'typeorm'; import { QualificationEntity } from './qualification.entity'; -import { UserEntity } from '../user'; -import { UserQualificationEntity } from './user-qualification.entity'; + +type FindOptions = { + relations?: FindManyOptions['relations']; +}; @Injectable() export class QualificationRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { super(QualificationEntity, dataSource); } async findByReference( reference: string, + options: FindOptions = {}, ): Promise { - const currentDate = new Date(); - const qualificationEntity = await this.findOne({ - where: [ - { reference, expiresAt: MoreThan(currentDate) }, - { reference, expiresAt: IsNull() }, - ], - relations: ['userQualifications', 'userQualifications.user'], + where: { reference }, + relations: options.relations, }); return qualificationEntity; } - async getQualifications(): Promise { - const currentDate = new Date(); - - return this.findBy([ - { expiresAt: MoreThan(currentDate) }, - { expiresAt: IsNull() }, - ]); - } - - async saveUserQualifications( - userQualifications: UserQualificationEntity[], - ): Promise { - // TODO: use base repository method for that - await this.dataSource - .getRepository(UserQualificationEntity) - .save(userQualifications); - } + async getActiveQualifications(): Promise { + const now = new Date(); - async removeUserQualifications( - users: UserEntity[], - qualification: QualificationEntity, - ): Promise { - const userQualifications = await this.dataSource - .getRepository(UserQualificationEntity) - .find({ - where: { - user: { id: In(users.map((user) => user.id)) }, - qualification: { id: qualification.id }, - }, - }); - await this.dataSource - .getRepository(UserQualificationEntity) - .remove(userQualifications); + return this.findBy([{ expiresAt: MoreThan(now) }, { expiresAt: IsNull() }]); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts index ed67d87cde..8d521f68ed 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts @@ -1,348 +1,344 @@ -import { Test } from '@nestjs/testing'; +import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; -import { QualificationService } from './qualification.service'; -import { QualificationRepository } from './qualification.repository'; -import { UserRepository } from '../user/user.repository'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; + +import { generateEthWallet } from '../../../test/fixtures/web3'; +import { ServerConfigService } from '../../config/server-config.service'; +import { UserStatus, UserRepository } from '../user'; +import { generateWorkerUser } from '../user/fixtures'; + +import { QualificationEntity } from './qualification.entity'; import { QualificationError, QualificationErrorMessage, } from './qualification.error'; -import { CreateQualificationDto } from './qualification.dto'; -import { QualificationEntity } from './qualification.entity'; +import { QualificationRepository } from './qualification.repository'; +import { QualificationService } from './qualification.service'; import { UserQualificationEntity } from './user-qualification.entity'; -import { ServerConfigService } from '../../config/server-config.service'; -import { ConfigService } from '@nestjs/config'; -import { mockConfig } from '../../../test/constants'; +import { UserQualificationRepository } from './user-qualification.repository'; + +const mockQualificationRepository = createMock(); +const mockUserQualificationRepository = + createMock(); +const mockUserRepository = createMock(); describe('QualificationService', () => { - let qualificationService: QualificationService; - let qualificationRepository: QualificationRepository; - let userRepository: UserRepository; + let service: QualificationService; - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ + beforeAll(async () => { + const module = await Test.createTestingModule({ providers: [ - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => mockConfig[key]), - getOrThrow: jest.fn((key: string) => { - if (!mockConfig[key]) { - throw new Error(`Configuration key "${key}" does not exist`); - } - return mockConfig[key]; - }), - }, - }, QualificationService, { provide: QualificationRepository, - useValue: createMock(), + useValue: mockQualificationRepository, + }, + { + provide: UserQualificationRepository, + useValue: mockUserQualificationRepository, + }, + { + provide: UserRepository, + useValue: mockUserRepository, }, - { provide: UserRepository, useValue: createMock() }, ServerConfigService, + ConfigService, ], }).compile(); - qualificationService = - moduleRef.get(QualificationService); - qualificationRepository = moduleRef.get( - QualificationRepository, - ); - userRepository = moduleRef.get(UserRepository); + service = module.get(QualificationService); }); - describe('createQualification', () => { - it('should create a new qualification', async () => { - const createQualificationDto: CreateQualificationDto = { - reference: 'ref1', - title: 'title1', - description: 'desc1', - expiresAt: '2025-12-31T00:00:00.000Z', - }; - - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = createQualificationDto.reference; - qualificationEntity.title = createQualificationDto.title; - qualificationEntity.description = createQualificationDto.description; - qualificationEntity.expiresAt = new Date( - createQualificationDto.expiresAt!, - ); + afterEach(() => { + jest.resetAllMocks(); + }); - qualificationRepository.save = jest - .fn() - .mockResolvedValue(qualificationEntity); + describe('createQualification', () => { + it.each([faker.date.future({ years: 1 }), undefined])( + 'should create a new qualification', + async (expiresAt) => { + const newQualification = { + title: faker.string.alpha(), + description: faker.string.alpha(), + expiresAt, + }; + + mockQualificationRepository.createUnique.mockImplementationOnce( + async (e) => e, + ); - const result = await qualificationService.createQualification( - createQualificationDto, - ); + const qualification = + await service.createQualification(newQualification); - expect(result).toEqual({ - reference: createQualificationDto.reference, - title: createQualificationDto.title, - description: createQualificationDto.description, - expiresAt: createQualificationDto.expiresAt, - }); - }); + expect(mockQualificationRepository.createUnique).toHaveBeenCalledTimes( + 1, + ); + expect(mockQualificationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...newQualification, + }), + ); + expect(qualification).toEqual({ + ...newQualification, + reference: expect.any(String), + expiresAt: newQualification.expiresAt?.toISOString(), + }); + }, + ); - it('should create a new qualification without expiresAt', async () => { - const createQualificationDto: CreateQualificationDto = { - reference: 'ref1', - title: 'title1', - description: 'desc1', + it('should throw a INVALID_EXPIRATION_TIME error', async () => { + const newQualification = { + title: faker.string.alpha(), + description: faker.string.alpha(), + expiresAt: faker.date.past(), }; - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = createQualificationDto.reference; - qualificationEntity.title = createQualificationDto.title; - qualificationEntity.description = createQualificationDto.description; - qualificationEntity.expiresAt = null; - - qualificationRepository.save = jest - .fn() - .mockResolvedValue(qualificationEntity); - - const result = await qualificationService.createQualification( - createQualificationDto, + let thrownError; + try { + await service.createQualification(newQualification); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(QualificationError); + expect(thrownError.message).toContain( + 'Qualification should be valid till at least', ); - - expect(result).toEqual({ - reference: createQualificationDto.reference, - title: createQualificationDto.title, - description: createQualificationDto.description, - }); + expect(mockQualificationRepository.createUnique).not.toHaveBeenCalled(); }); + }); - it('should throw an error if the expiration date is in the past', async () => { - const createQualificationDto: CreateQualificationDto = { - reference: 'ref1', - title: 'title1', - description: 'desc1', - expiresAt: '2000-01-01T00:00:00.000Z', + describe('deleteQualification', () => { + it('should delete qualification by reference', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), }; - const errorMessage = - QualificationErrorMessage.INVALID_EXPIRATION_TIME.replace( - '%minValidity%', - '1', - ); - - await expect( - qualificationService.createQualification(createQualificationDto), - ).rejects.toThrow( - new QualificationError( - errorMessage as QualificationErrorMessage, - 'ref1', - ), + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, ); - }); - - it('should throw an error if the qualification has not beed created', async () => { - const createQualificationDto: CreateQualificationDto = { - reference: 'ref1', - title: 'title1', - description: 'desc1', - expiresAt: '2025-12-31T00:00:00.000Z', - }; - qualificationRepository.createUnique = jest - .fn() - .mockRejectedValueOnce(new Error()); - - await expect( - qualificationService.createQualification(createQualificationDto), - ).rejects.toThrow(Error); - }); - }); - - describe('getQualifications', () => { - it('should return a list of qualifications', async () => { - const qualifications = [ - { - reference: 'ref1', - title: 'title1', - description: 'desc1', - }, - ]; - qualificationRepository.getQualifications = jest - .fn() - .mockResolvedValue(qualifications); + mockUserQualificationRepository.findByQualification.mockResolvedValueOnce( + [], + ); - const result = await qualificationService.getQualifications(); + await service.deleteQualification(reference); - expect(result).toEqual(qualifications); + expect(mockQualificationRepository.deleteOne).toHaveBeenCalledTimes(1); + expect(mockQualificationRepository.deleteOne).toHaveBeenCalledWith( + qualificationEntity, + ); }); - it('should return a list of qualifications with null expiresAt', async () => { - const qualifications = [ - { - reference: 'ref1', - title: 'title1', - description: 'desc1', - }, - ]; - qualificationRepository.getQualifications = jest - .fn() - .mockResolvedValue(qualifications); + it('should throw NOT_FOUND error', async () => { + const reference = faker.string.uuid(); + mockQualificationRepository.findByReference.mockResolvedValueOnce(null); - const result = await qualificationService.getQualifications(); - - expect(result).toEqual(qualifications); + await expect(service.deleteQualification(reference)).rejects.toThrow( + new QualificationError(QualificationErrorMessage.NOT_FOUND, reference), + ); }); - }); - - describe('delete', () => { - it('should delete a qualification by reference', async () => { - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = 'ref1'; - qualificationEntity.userQualifications = []; - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(qualificationEntity); + it('should throw CANNOT_DETELE_ASSIGNED_QUALIFICATION error', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; - qualificationRepository.deleteOne = jest - .fn() - .mockResolvedValue(undefined); + const mockUserQualificationEntity = { + userId: faker.number.int(), + qualificationId: faker.number.int(), + }; - await expect( - qualificationService.delete('ref1'), - ).resolves.toBeUndefined(); - }); + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); - it('should throw an error if the qualification is not found', async () => { - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(undefined); + mockUserQualificationRepository.findByQualification.mockResolvedValueOnce( + [mockUserQualificationEntity] as UserQualificationEntity[], + ); - await expect(qualificationService.delete('ref1')).rejects.toThrow( - new QualificationError(QualificationErrorMessage.NOT_FOUND, 'ref1'), + await expect(service.deleteQualification(reference)).rejects.toThrow( + new QualificationError( + QualificationErrorMessage.CANNOT_DETELE_ASSIGNED_QUALIFICATION, + reference, + ), ); }); }); describe('assign', () => { - beforeEach(() => { - qualificationRepository.saveUserQualifications = jest.fn(); - }); - - it('should assign users to a qualification', async () => { - const reference = 'ref1'; - const workerAddresses = ['address1']; + it('should assign user to qualification', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = reference; - qualificationEntity.userQualifications = []; + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); + mockUserRepository.findWorkersByAddresses.mockResolvedValueOnce([user]); - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(qualificationEntity); - userRepository.findWorkersByAddresses = jest - .fn() - .mockResolvedValue([{ id: 1 }]); + const result = await service.assign(reference, [ + user.evmAddress as string, + ]); - await qualificationService.assign(reference, workerAddresses); + expect(result).toEqual({ + success: [user.evmAddress], + failed: [], + }); expect( - qualificationRepository.saveUserQualifications, + mockUserQualificationRepository.createUnique, ).toHaveBeenCalledTimes(1); }); - it('should assign users to a qualification with null expiresAt', async () => { - const reference = 'ref1'; - const workerAddresses = ['address1']; + it('should fail to assign user not in active status', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + status: UserStatus.INACTIVE, + }); - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = reference; - qualificationEntity.expiresAt = null; - qualificationEntity.userQualifications = []; + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); + mockUserRepository.findWorkersByAddresses.mockResolvedValueOnce([user]); - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(qualificationEntity); - userRepository.findWorkersByAddresses = jest - .fn() - .mockResolvedValue([{ id: 1 }]); + const result = await service.assign(reference, [ + user.evmAddress as string, + ]); - await qualificationService.assign(reference, workerAddresses); + expect(result).toEqual({ + success: [], + failed: [ + { + evmAddress: user.evmAddress as string, + reason: 'User is not in active status', + }, + ], + }); expect( - qualificationRepository.saveUserQualifications, - ).toHaveBeenCalledTimes(1); + mockUserQualificationRepository.createUnique, + ).not.toHaveBeenCalled(); }); - it('should throw an error if the qualification is not found', async () => { - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(null); + it('should throw NOT_FOUND error', async () => { + const reference = faker.string.uuid(); - await expect(qualificationService.assign('ref1', [])).rejects.toThrow( - new QualificationError(QualificationErrorMessage.NOT_FOUND, 'ref1'), + mockQualificationRepository.findByReference.mockResolvedValueOnce(null); + + await expect( + service.assign(reference, [faker.finance.ethereumAddress()]), + ).rejects.toThrow( + new QualificationError(QualificationErrorMessage.NOT_FOUND, reference), ); }); - }); - describe('unassign', () => { - beforeEach(() => { - qualificationRepository.saveUserQualifications = jest.fn(); + it('should throw NO_WORKERS_FOUND error', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; + + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); + + mockUserRepository.findWorkersByAddresses.mockResolvedValueOnce([]); + + await expect( + service.assign(reference, [faker.finance.ethereumAddress()]), + ).rejects.toThrow( + new QualificationError( + QualificationErrorMessage.NO_WORKERS_FOUND, + reference, + ), + ); }); + }); - it('should unassign users from a qualification', async () => { - const reference = 'ref1'; - const workerAddresses = ['address1']; + describe('unassign', () => { + it('should unassign user from a qualification', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = reference; - qualificationEntity.userQualifications = [ - { id: 1 } as UserQualificationEntity, - ]; + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); + mockUserRepository.findWorkersByAddresses.mockResolvedValueOnce([user]); - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(qualificationEntity); - userRepository.findWorkersByAddresses = jest - .fn() - .mockResolvedValue([{ id: 1 }]); + const result = await service.unassign(reference, [ + user.evmAddress as string, + ]); - await qualificationService.unassign(reference, workerAddresses); + expect(result).toEqual({ + success: [user.evmAddress], + failed: [], + }); expect( - qualificationRepository.saveUserQualifications, - ).toHaveBeenCalledTimes(0); + mockUserQualificationRepository.removeByUserAndQualification, + ).toHaveBeenCalledTimes(1); }); - it('should unassign users from a qualification with null expiresAt', async () => { - const reference = 'ref1'; - const workerAddresses = ['address1']; + it('should throw NOT_FOUND error', async () => { + const reference = faker.string.uuid(); + mockQualificationRepository.findByReference.mockResolvedValueOnce(null); - const qualificationEntity = new QualificationEntity(); - qualificationEntity.reference = reference; - qualificationEntity.expiresAt = null; - qualificationEntity.userQualifications = [ - { id: 1 } as UserQualificationEntity, - ]; - - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(qualificationEntity); - userRepository.findWorkersByAddresses = jest - .fn() - .mockResolvedValue([{ id: 1 }]); + await expect( + service.unassign(reference, [faker.finance.ethereumAddress()]), + ).rejects.toThrow( + new QualificationError(QualificationErrorMessage.NOT_FOUND, reference), + ); + }); - await qualificationService.unassign(reference, workerAddresses); + it('should throw NO_WORKERS_FOUND error', async () => { + const reference = faker.string.uuid(); + const qualificationEntity = { + reference, + title: faker.string.alpha(), + description: faker.string.alpha(), + }; - expect( - qualificationRepository.saveUserQualifications, - ).toHaveBeenCalledTimes(0); - }); + mockQualificationRepository.findByReference.mockResolvedValueOnce( + qualificationEntity as QualificationEntity, + ); - it('should throw an error if the qualification is not found', async () => { - qualificationRepository.findByReference = jest - .fn() - .mockResolvedValue(null); + mockUserRepository.findWorkersByAddresses.mockResolvedValueOnce([]); - await expect(qualificationService.unassign('ref1', [])).rejects.toThrow( - new QualificationError(QualificationErrorMessage.NOT_FOUND, 'ref1'), + await expect( + service.unassign(reference, [faker.finance.ethereumAddress()]), + ).rejects.toThrow( + new QualificationError( + QualificationErrorMessage.NO_WORKERS_FOUND, + reference, + ), ); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts index ee12c178ba..cacd799f8f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts @@ -1,15 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { CreateQualificationDto, QualificationDto } from './qualification.dto'; -import { QualificationEntity } from './qualification.entity'; -import { QualificationRepository } from './qualification.repository'; -import { UserRepository, UserStatus } from '../user'; -import { UserQualificationEntity } from './user-qualification.entity'; +import { v4 as uuidV4 } from 'uuid'; + import { ServerConfigService } from '../../config/server-config.service'; +import logger from '../../logger'; +import { UserRepository, UserStatus } from '../user'; + +import { QualificationEntity } from './qualification.entity'; import { QualificationError, QualificationErrorMessage, } from './qualification.error'; -import logger from '../../logger'; +import { QualificationRepository } from './qualification.repository'; +import { UserQualificationEntity } from './user-qualification.entity'; +import { UserQualificationRepository } from './user-qualification.repository'; + +type Qualification = { + reference: string; + title: string; + description: string; + expiresAt?: string; +}; @Injectable() export class QualificationService { @@ -19,43 +29,36 @@ export class QualificationService { constructor( private readonly qualificationRepository: QualificationRepository, + private readonly userQualificationRepository: UserQualificationRepository, private readonly userRepository: UserRepository, private readonly serverConfigService: ServerConfigService, ) {} - async createQualification( - createQualificationDto: CreateQualificationDto, - ): Promise { + async createQualification(qualification: { + title: string; + description: string; + expiresAt?: Date; + }): Promise { const newQualification = new QualificationEntity(); - newQualification.reference = createQualificationDto.reference; - newQualification.title = createQualificationDto.title; - newQualification.description = createQualificationDto.description; + newQualification.reference = uuidV4(); + newQualification.title = qualification.title; + newQualification.description = qualification.description; - if (createQualificationDto.expiresAt) { - const providedExpirationTime = new Date(createQualificationDto.expiresAt); - const now = new Date(); + if (qualification.expiresAt) { + const now = Date.now(); const minimumValidUntil = new Date( - now.getTime() + - this.serverConfigService.qualificationMinValidity * - 24 * - 60 * - 60 * - 1000, // Convert days to milliseconds, + now + this.serverConfigService.qualificationMinValidity, ); - if (providedExpirationTime <= minimumValidUntil) { + if (qualification.expiresAt <= minimumValidUntil) { const errorMessage = QualificationErrorMessage.INVALID_EXPIRATION_TIME.replace( - '%minValidity%', - this.serverConfigService.qualificationMinValidity.toString(), + '%minExpirationDate%', + minimumValidUntil.toISOString(), ); - throw new QualificationError( - errorMessage as QualificationErrorMessage, - createQualificationDto.reference, - ); - } else { - newQualification.expiresAt = providedExpirationTime; + throw new QualificationError(errorMessage as QualificationErrorMessage); } + newQualification.expiresAt = qualification.expiresAt; } await this.qualificationRepository.createUnique(newQualification); @@ -67,10 +70,10 @@ export class QualificationService { }; } - async getQualifications(): Promise { + async getQualifications(): Promise { try { const qualificationEntities = - await this.qualificationRepository.getQualifications(); + await this.qualificationRepository.getActiveQualifications(); return qualificationEntities.map((qualificationEntity) => { return { @@ -86,7 +89,7 @@ export class QualificationService { } } - async delete(reference: string): Promise { + async deleteQualification(reference: string): Promise { const qualificationEntity = await this.qualificationRepository.findByReference(reference); @@ -97,7 +100,11 @@ export class QualificationService { ); } - if (qualificationEntity.userQualifications.length > 0) { + const userQualifications = + await this.userQualificationRepository.findByQualification( + qualificationEntity.id, + ); + if (userQualifications.length > 0) { throw new QualificationError( QualificationErrorMessage.CANNOT_DETELE_ASSIGNED_QUALIFICATION, reference, @@ -107,7 +114,13 @@ export class QualificationService { await this.qualificationRepository.deleteOne(qualificationEntity); } - async assign(reference: string, workerAddresses: string[]): Promise { + async assign( + reference: string, + workerAddresses: string[], + ): Promise<{ + success: string[]; + failed: { evmAddress: string; reason: string }[]; + }> { const qualificationEntity = await this.qualificationRepository.findByReference(reference); @@ -128,43 +141,46 @@ export class QualificationService { ); } - const newUserQualifications = users - .filter((user) => { - if (user.status !== UserStatus.ACTIVE) { - return false; - } + const result = { + success: [] as string[], + failed: [] as { evmAddress: string; reason: string }[], + }; - const hasDesiredQualification = - qualificationEntity.userQualifications.some( - (uq) => uq.user.id === user.id, - ); - if (hasDesiredQualification) { - return false; + for (const user of users) { + try { + if (user.status !== UserStatus.ACTIVE) { + throw new Error('User is not in active status'); } - - return true; - }) - .map((user) => { const userQualification = new UserQualificationEntity(); - userQualification.user = user; - userQualification.qualification = qualificationEntity; - - /** - * TODO: remove this when using base repository - */ - const date = new Date(); - userQualification.createdAt = date; - userQualification.updatedAt = date; - - return userQualification; - }); - - await this.qualificationRepository.saveUserQualifications( - newUserQualifications, - ); + userQualification.qualificationId = qualificationEntity.id; + userQualification.userId = user.id; + + await this.userQualificationRepository.createUnique(userQualification); + + result.success.push(user.evmAddress as string); + } catch (error) { + this.logger.error('Cannot assign user to qualification', { + user: user.id, + qualification: reference, + reason: error.message, + }); + + result.failed.push({ + evmAddress: user.evmAddress as string, + reason: error.message, + }); + } + } + return result; } - async unassign(reference: string, workerAddresses: string[]): Promise { + async unassign( + reference: string, + workerAddresses: string[], + ): Promise<{ + success: string[]; + failed: { evmAddress: string; reason: string }[]; + }> { const qualificationEntity = await this.qualificationRepository.findByReference(reference); @@ -185,9 +201,31 @@ export class QualificationService { ); } - await this.qualificationRepository.removeUserQualifications( - users, - qualificationEntity, - ); + const result = { + success: [] as string[], + failed: [] as { evmAddress: string; reason: string }[], + }; + + for (const user of users) { + try { + await this.userQualificationRepository.removeByUserAndQualification( + user.id, + qualificationEntity.id, + ); + result.success.push(user.evmAddress as string); + } catch (error) { + this.logger.error('Cannot unassign user from qualification', { + user: user.id, + qualification: reference, + reason: error.message, + }); + result.failed.push({ + evmAddress: user.evmAddress as string, + reason: error.message, + }); + } + } + + return result; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts index bb2631273a..5697d14232 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts @@ -1,17 +1,25 @@ -import { Entity, ManyToOne } from 'typeorm'; +import { Column, Entity, ManyToOne, Index } from 'typeorm'; + import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -import type { UserEntity } from '../user'; import type { QualificationEntity } from '../qualification/qualification.entity'; +import type { UserEntity } from '../user'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'user_qualifications' }) +@Index(['user', 'qualification'], { unique: true }) export class UserQualificationEntity extends BaseEntity { @ManyToOne('UserEntity', (user: UserEntity) => user.userQualifications) - public user: UserEntity; + user?: UserEntity; + + @Column({ type: 'int' }) + userId: number; @ManyToOne( 'QualificationEntity', (qualification: QualificationEntity) => qualification.userQualifications, ) - public qualification: QualificationEntity; + qualification?: QualificationEntity; + + @Column({ type: 'int' }) + qualificationId: number; } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.repository.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.repository.ts new file mode 100644 index 0000000000..b610be12bf --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { BaseRepository } from '../../database/base.repository'; +import { UserQualificationEntity } from './user-qualification.entity'; + +@Injectable() +export class UserQualificationRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(UserQualificationEntity, dataSource); + } + + async removeByUserAndQualification( + userId: number, + qualificationId: number, + ): Promise { + await this.delete({ userId, qualificationId }); + } + + async findByQualification( + qualificationId: number, + ): Promise { + return this.findBy({ qualificationId }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts index b1a49d865f..68f3c5b04a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts @@ -1,6 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { ChainId, EscrowClient } from '@human-protocol/sdk'; -import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { ReputationService } from './reputation.service'; import { ReputationRepository } from './reputation.repository'; @@ -8,12 +8,6 @@ import { ReputationEntity } from './reputation.entity'; import { MOCK_ADDRESS, MOCK_FILE_URL, - MOCK_S3_ACCESS_KEY, - MOCK_S3_BUCKET, - MOCK_S3_ENDPOINT, - MOCK_S3_PORT, - MOCK_S3_SECRET_KEY, - MOCK_S3_USE_SSL, mockConfig, } from '../../../test/constants'; import { @@ -53,18 +47,6 @@ describe('ReputationService', () => { beforeEach(async () => { const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forFeature( - registerAs('s3', () => ({ - accessKey: MOCK_S3_ACCESS_KEY, - secretKey: MOCK_S3_SECRET_KEY, - endPoint: MOCK_S3_ENDPOINT, - port: MOCK_S3_PORT, - useSSL: MOCK_S3_USE_SSL, - bucket: MOCK_S3_BUCKET, - })), - ), - ], providers: [ { provide: ConfigService, diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts index a8db78fbf7..008110bdba 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ChainId } from '@human-protocol/sdk'; import { AUDINO_VALIDATION_META_FILENAME, @@ -27,7 +27,11 @@ import { } from '../../common/interfaces/job-result'; import { RequestAction } from './reputation.interface'; import { getRequestType } from '../../utils/manifest'; -import { AudinoManifest, CvatManifest } from '../../common/interfaces/manifest'; +import { + AudinoManifest, + CvatManifest, + JobManifest, +} from '../../common/interfaces/manifest'; import { ReputationConfigService } from '../../config/reputation-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import { ReputationEntity } from './reputation.entity'; @@ -36,7 +40,6 @@ import { ReputationError, ReputationErrorMessage } from './reputation.error'; @Injectable() export class ReputationService { constructor( - @Inject(StorageService) private readonly storageService: StorageService, private readonly reputationRepository: ReputationRepository, private readonly reputationConfigService: ReputationConfigService, @@ -62,7 +65,7 @@ export class ReputationService { const manifestUrl = await escrowClient.getManifestUrl(escrowAddress); const manifest = - await this.storageService.downloadJsonLikeData(manifestUrl); + await this.storageService.downloadJsonLikeData(manifestUrl); const requestType = getRequestType(manifest); @@ -172,12 +175,14 @@ export class ReputationService { const finalResultsUrl = await escrowClient.getResultsUrl(escrowAddress); const finalResults = - await this.storageService.downloadJsonLikeData(finalResultsUrl); + await this.storageService.downloadJsonLikeData( + finalResultsUrl, + ); // Assess reputation scores for workers based on the final results of a job. // Decreases or increases worker reputation based on the success or failure of their contributions. await Promise.all( - finalResults.map(async (result: FortuneFinalResult) => { + finalResults.map(async (result) => { if (result.error) { if (result.error === SolutionError.Duplicated) await this.decreaseReputation( @@ -207,8 +212,8 @@ export class ReputationService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const annotations: CvatAnnotationMeta = - await this.storageService.downloadJsonLikeData( + const annotations = + await this.storageService.downloadJsonLikeData( `${intermediateResultsUrl}/${CVAT_VALIDATION_META_FILENAME}`, ); @@ -244,8 +249,8 @@ export class ReputationService { const intermediateResultsUrl = await escrowClient.getIntermediateResultsUrl(escrowAddress); - const annotations: AudinoAnnotationMeta = - await this.storageService.downloadJsonLikeData( + const annotations = + await this.storageService.downloadJsonLikeData( `${intermediateResultsUrl}/${AUDINO_VALIDATION_META_FILENAME}`, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/minio.constants.ts b/packages/apps/reputation-oracle/server/src/modules/storage/minio.constants.ts new file mode 100644 index 0000000000..745299d7b1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/storage/minio.constants.ts @@ -0,0 +1,3 @@ +export enum MinioErrorCodes { + NotFound = 'NotFound', +} diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/storage.errors.ts b/packages/apps/reputation-oracle/server/src/modules/storage/storage.errors.ts deleted file mode 100644 index b4eecc4ce4..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/storage/storage.errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BaseError } from '../../common/errors/base'; - -export class FileDownloadError extends BaseError { - public readonly location: string; - - constructor(location: string, cause?: unknown) { - super('Failed to download file', cause); - - this.location = location; - } -} - -export class InvalidFileUrl extends FileDownloadError { - constructor(url: string) { - super(url); - this.message = 'Invalid file URL'; - } -} - -export class FileNotFoundError extends FileDownloadError { - constructor(location: string) { - super(location); - this.message = 'File not found'; - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/storage.module.ts b/packages/apps/reputation-oracle/server/src/modules/storage/storage.module.ts index f11194bc07..22d4e94a5a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/storage/storage.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/storage/storage.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { Web3Module } from '../web3/web3.module'; +import { EncryptionModule } from '../encryption/encryption.module'; import { StorageService } from './storage.service'; @Module({ - imports: [Web3Module], + imports: [EncryptionModule], providers: [StorageService], exports: [StorageService], }) diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts index 605660b6f3..6f9523a70a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts @@ -1,580 +1,215 @@ -import { - ChainId, - Encryption, - EncryptionUtils, - EscrowClient, - KVStoreUtils, -} from '@human-protocol/sdk'; -import { ConfigService } from '@nestjs/config'; +jest.mock('minio'); +import { createMock } from '@golevelup/ts-jest'; +import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; -import { - MOCK_ENCRYPTION_PUBLIC_KEY, - MOCK_FILE_HASH, - MOCK_FILE_URL, - mockConfig, -} from '../../../test/constants'; -import { StorageService } from './storage.service'; -import crypto from 'crypto'; -import { Web3Service } from '../web3/web3.service'; -import { PGPConfigService } from '../../config/pgp-config.service'; +import { Client as MinioClient } from 'minio'; + +import { ContentType } from '../../common/enums'; import { S3ConfigService } from '../../config/s3-config.service'; +import * as httpUtils from '../../utils/http'; -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - EscrowClient: { - build: jest.fn(), - }, - Encryption: { - build: jest.fn(), - }, - EncryptionUtils: { - encrypt: jest.fn(), - isEncrypted: jest.fn(), - }, - KVStoreUtils: { - getPublicKey: jest.fn(), - }, -})); - -jest.mock('minio', () => { - class Client { - putObject = jest.fn(); - bucketExists = jest.fn(); - statObject = jest.fn(); - constructor() { - (this as any).protocol = 'http:'; - (this as any).host = 'localhost'; - (this as any).port = 9000; - } - } - - return { Client }; -}); +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; -jest.mock('axios'); +import { MinioErrorCodes } from './minio.constants'; +import { StorageService } from './storage.service'; + +const mockedMinioClientInstance = { + statObject: jest.fn(), + bucketExists: jest.fn(), + putObject: jest.fn(), +}; +jest + .mocked(MinioClient) + .mockImplementation( + () => mockedMinioClientInstance as unknown as MinioClient, + ); + +const mockedPgpEncryptionService = createMock(); + +const mockS3ConfigService: Omit = { + endpoint: faker.internet.domainName(), + port: faker.internet.port(), + accessKey: faker.internet.password(), + secretKey: faker.internet.password(), + bucket: faker.lorem.word(), + useSSL: true, +}; + +function constructExpectedS3FileUrl(fileName: string) { + return ( + 'https://' + + `${mockS3ConfigService.endpoint}:${mockS3ConfigService.port}/` + + `${mockS3ConfigService.bucket}/${fileName}` + ); +} describe('StorageService', () => { let storageService: StorageService; - let pgpConfigService: PGPConfigService; - let s3ConfigService: S3ConfigService; - let downloadFileFromUrlSpy: jest.SpyInstance; - - const signerMock = { - address: '0x1234567890123456789012345678901234567892', - getNetwork: jest.fn().mockResolvedValue({ chainId: ChainId.LOCALHOST }), - }; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ - StorageService, { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => mockConfig[key]), - getOrThrow: jest.fn((key: string) => { - if (!mockConfig[key]) { - throw new Error(`Configuration key "${key}" does not exist`); - } - return mockConfig[key]; - }), - }, + provide: S3ConfigService, + useValue: mockS3ConfigService, }, - PGPConfigService, - S3ConfigService, + StorageService, { - provide: Web3Service, - useValue: { - getSigner: jest.fn().mockReturnValue(signerMock), - }, + provide: PgpEncryptionService, + useValue: mockedPgpEncryptionService, }, ], }).compile(); storageService = moduleRef.get(StorageService); - pgpConfigService = moduleRef.get(PGPConfigService); - s3ConfigService = moduleRef.get(S3ConfigService); - - const jobLauncherAddress = '0x1234567890123456789012345678901234567893'; - EscrowClient.build = jest.fn().mockResolvedValue({ - getJobLauncherAddress: jest.fn().mockResolvedValue(jobLauncherAddress), - }); - KVStoreUtils.getPublicKey = jest - .fn() - .mockResolvedValue(MOCK_ENCRYPTION_PUBLIC_KEY); - jest.spyOn(pgpConfigService, 'encrypt', 'get').mockReturnValue(true); }); - beforeEach(() => { - downloadFileFromUrlSpy = jest.spyOn(StorageService, 'downloadFileFromUrl'); + afterEach(() => { + jest.resetAllMocks(); }); - describe('uploadJobSolutions', () => { - it('should upload the solutions correctly', async () => { - const workerAddress = '0x1234567890123456789012345678901234567891'; - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - const solution = 'test'; - - storageService.minioClient.bucketExists = jest - .fn() - .mockResolvedValueOnce(true); - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - - EncryptionUtils.encrypt = jest.fn().mockResolvedValueOnce('encrypted'); - - const jobSolution = { - workerAddress, - solution, - }; - const fileData = await storageService.uploadJobSolutions( - escrowAddress, - chainId, - [jobSolution], - ); + describe('uploadData', () => { + it('should throw if configured bucket does not exist', async () => { + mockedMinioClientInstance.bucketExists.mockImplementation( + (bucketName) => { + if (bucketName === mockS3ConfigService.bucket) { + return false; + } - const hash = crypto.createHash('sha1').update('encrypted').digest('hex'); - expect(fileData).toEqual({ - url: `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/${hash}.json`, - hash, - }); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `${hash}.json`, - expect.stringContaining('encrypted'), - { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', + return true; }, ); - }); - describe('without encryption', () => { - beforeAll(() => { - jest.spyOn(pgpConfigService, 'encrypt', 'get').mockReturnValue(false); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - afterAll(() => { - jest.spyOn(pgpConfigService, 'encrypt', 'get').mockReturnValue(true); - }); + await expect( + storageService.uploadData( + faker.string.sample(), + faker.system.fileName(), + ContentType.PLAIN_TEXT, + ), + ).rejects.toThrow("Can't find configured bucket"); - it('should upload the solutions', async () => { - const workerAddress = '0x1234567890123456789012345678901234567891'; - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - const solution = 'test'; - - storageService.minioClient.bucketExists = jest - .fn() - .mockResolvedValueOnce(true); - - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - - EncryptionUtils.encrypt = jest.fn().mockResolvedValueOnce('encrypted'); - - const jobSolution = { - workerAddress, - solution, - }; - const fileData = await storageService.uploadJobSolutions( - escrowAddress, - chainId, - [jobSolution], - ); - - const content = JSON.stringify([jobSolution]); - const hash = crypto.createHash('sha1').update(content).digest('hex'); - expect(fileData).toEqual({ - url: `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/${hash}.json`, - hash, - }); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `${hash}.json`, - expect.stringContaining(content), - { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }, - ); - }); + expect(mockedMinioClientInstance.putObject).toHaveBeenCalledTimes(0); }); - it('should return the URL of the file and a hash if it already exists', async () => { - const workerAddress = '0x1234567890123456789012345678901234567891'; - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - const solution = 'test'; - - const hash = crypto.createHash('sha1').update('encrypted').digest('hex'); - const results = { - url: `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/${hash}.json`, - hash, - }; - - storageService.minioClient.bucketExists = jest - .fn() - .mockResolvedValueOnce(true); - storageService.minioClient.statObject = jest - .fn() - .mockResolvedValueOnce({ url: MOCK_FILE_URL, hash: MOCK_FILE_HASH }); + it('should not upload if file already exists', async () => { + mockedMinioClientInstance.bucketExists.mockResolvedValueOnce(true); - EncryptionUtils.encrypt = jest.fn().mockResolvedValueOnce('encrypted'); + const fileName = `${faker.lorem.word()}.json`; + mockedMinioClientInstance.statObject.mockImplementation( + (bucketName, key) => { + if (bucketName === mockS3ConfigService.bucket && key === fileName) { + return {}; + } - const jobSolution = { - workerAddress, - solution, - }; - - const result = await storageService.uploadJobSolutions( - escrowAddress, - chainId, - [jobSolution], + throw { + code: MinioErrorCodes.NotFound, + }; + }, ); - expect(result).toEqual(results); - }); - - it('should fail if the bucket does not exist', async () => { - const workerAddress = '0x1234567890123456789012345678901234567891'; - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - const solution = 'test'; - - storageService.minioClient.bucketExists = jest - .fn() - .mockResolvedValueOnce(false); - - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - - const jobSolution = { - workerAddress, - solution, - }; - await expect( - storageService.uploadJobSolutions(escrowAddress, chainId, [ - jobSolution, - ]), - ).rejects.toThrow('Bucket not found'); - }); - - it('should fail if the file cannot be uploaded', async () => { - const workerAddress = '0x1234567890123456789012345678901234567891'; - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - const solution = 'test'; - - storageService.minioClient.bucketExists = jest - .fn() - .mockResolvedValueOnce(true); - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - storageService.minioClient.putObject = jest - .fn() - .mockRejectedValueOnce('Network error'); - - const jobSolution = { - workerAddress, - solution, - }; - - await expect( - storageService.uploadJobSolutions(escrowAddress, chainId, [ - jobSolution, - ]), - ).rejects.toThrow('File not uploaded'); - }); - }); - - describe('downloadJsonLikeData', () => { - it('should download data correctly', async () => { - const exchangeAddress = '0x1234567890123456789012345678901234567892'; - const workerAddress = '0x1234567890123456789012345678901234567891'; - const solution = 'test'; - - const expectedJobJson = { - exchangeAddress, - solutions: [ - { - workerAddress, - solution, - }, - ], - }; - - downloadFileFromUrlSpy.mockResolvedValueOnce( - Buffer.from(JSON.stringify(expectedJobJson)), + const fileUrl = await storageService.uploadData( + JSON.stringify({ test: faker.string.sample() }), + fileName, + ContentType.JSON, ); - - const solutionsJson = - await storageService.downloadJsonLikeData(MOCK_FILE_URL); - expect(solutionsJson).toEqual(expectedJobJson); + expect(fileUrl).toBe(constructExpectedS3FileUrl(fileName)); + expect(mockedMinioClientInstance.putObject).toHaveBeenCalledTimes(0); }); - it('should download the encrypted data correctly', async () => { - const exchangeAddress = '0x1234567890123456789012345678901234567892'; - const workerAddress = '0x1234567890123456789012345678901234567891'; - const solution = 'test'; - - const expectedJobJson = { - exchangeAddress, - solutions: [ - { - workerAddress, - solution, - }, - ], - }; + it('should upload if file does not exists', async () => { + mockedMinioClientInstance.bucketExists.mockResolvedValueOnce(true); - downloadFileFromUrlSpy.mockResolvedValueOnce(Buffer.from('encrypted')); - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(true); - Encryption.build = jest.fn().mockResolvedValue({ - decrypt: jest.fn().mockResolvedValue(JSON.stringify(expectedJobJson)), + const fileName = faker.system.fileName(); + mockedMinioClientInstance.statObject.mockRejectedValue({ + code: MinioErrorCodes.NotFound, }); + const fileContent = Buffer.from(faker.string.sample()); + const contentType = ContentType.BINARY; - const solutionsJson = - await storageService.downloadJsonLikeData(MOCK_FILE_URL); - expect(solutionsJson).toEqual(expectedJobJson); - }); - - it('should return empty array when data cannot be downloaded', async () => { - downloadFileFromUrlSpy.mockRejectedValue('Network error'); - - const solutionsJson = - await storageService.downloadJsonLikeData(MOCK_FILE_URL); - expect(solutionsJson).toEqual([]); - }); - }); - - describe('copyFileFromURLToBucket', () => { - const someFileContent = Buffer.from('some-file-content'); - const escrowAddress = '0x1234567890123456789012345678901234567890'; - const chainId = ChainId.LOCALHOST; - - it('should copy a file from a valid URL to a bucket', async () => { - downloadFileFromUrlSpy.mockResolvedValueOnce(someFileContent); - - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(false); - EncryptionUtils.encrypt = jest - .fn() - .mockResolvedValueOnce('encrypted-file-content'); - - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - storageService.minioClient.putObject = jest - .fn() - .mockResolvedValueOnce(true); - - const uploadedFile = await storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - MOCK_FILE_URL, + const fileUrl = await storageService.uploadData( + fileContent, + fileName, + contentType, ); - - expect( - uploadedFile.url.includes( - `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/`, - ), - ).toBeTruthy(); - expect(uploadedFile.hash).toBeDefined(); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `s3${crypto - .createHash('sha1') - .update('encrypted-file-content') - .digest('hex')}.zip`, - 'encrypted-file-content', + expect(fileUrl).toBe(constructExpectedS3FileUrl(fileName)); + expect(mockedMinioClientInstance.putObject).toHaveBeenCalledTimes(1); + expect(mockedMinioClientInstance.putObject).toHaveBeenCalledWith( + mockS3ConfigService.bucket, + fileName, + fileContent, { + 'Content-Type': contentType, 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain', }, ); }); + }); - it('should copy an encrypted file from a valid URL to a bucket', async () => { - downloadFileFromUrlSpy.mockResolvedValueOnce(someFileContent); - Encryption.build = jest.fn().mockResolvedValue({ - decrypt: jest.fn().mockResolvedValue('decrypted-file-content'), - }); + describe('downloadJsonLikeData', () => { + const EXPECTED_DOWNLOAD_ERROR_MESSAGE = 'Error downloading json like data'; + const spyOnDownloadFile = jest.spyOn(httpUtils, 'downloadFile'); - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(true); - EncryptionUtils.encrypt = jest - .fn() - .mockResolvedValueOnce('encrypted-file-content'); + beforeAll(() => { + spyOnDownloadFile.mockImplementation(); + }); - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); + afterAll(() => { + spyOnDownloadFile.mockRestore(); + }); - const uploadedFile = await storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - MOCK_FILE_URL, - ); + it('should throw custom error when fails to load file', async () => { + spyOnDownloadFile.mockRejectedValueOnce(new Error(faker.lorem.word())); - expect( - uploadedFile.url.includes( - `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/`, - ), - ).toBeTruthy(); - expect(uploadedFile.hash).toBeDefined(); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `s3${crypto - .createHash('sha1') - .update('encrypted-file-content') - .digest('hex')}.zip`, - 'encrypted-file-content', - { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain', - }, - ); + await expect( + storageService.downloadJsonLikeData(faker.internet.url()), + ).rejects.toThrow(EXPECTED_DOWNLOAD_ERROR_MESSAGE); }); - it('should return the URL of the file and a hash if it already exists', async () => { - downloadFileFromUrlSpy.mockResolvedValueOnce(someFileContent); - Encryption.build = jest.fn().mockResolvedValue({ - decrypt: jest.fn().mockResolvedValue('decrypted-file-content'), - }); - - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(true); - EncryptionUtils.encrypt = jest - .fn() - .mockResolvedValueOnce('encrypted-file-content'); + it('should throw custom error when fails to decrypt', async () => { + spyOnDownloadFile.mockResolvedValueOnce(Buffer.from('')); + mockedPgpEncryptionService.maybeDecryptFile.mockRejectedValueOnce( + new Error(faker.lorem.word()), + ); - storageService.minioClient.statObject = jest - .fn() - .mockResolvedValueOnce({ url: MOCK_FILE_URL, hash: MOCK_FILE_HASH }); + await expect( + storageService.downloadJsonLikeData(faker.internet.url()), + ).rejects.toThrow(EXPECTED_DOWNLOAD_ERROR_MESSAGE); + }); - const uploadedFile = await storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - MOCK_FILE_URL, + it('should throw custom error when fails to parse data', async () => { + spyOnDownloadFile.mockResolvedValueOnce(Buffer.from(faker.lorem.words())); + mockedPgpEncryptionService.maybeDecryptFile.mockImplementationOnce( + async (c) => c, ); - expect( - uploadedFile.url.includes( - `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/`, - ), - ).toBeTruthy(); - expect(uploadedFile.hash).toBeDefined(); + await expect( + storageService.downloadJsonLikeData(faker.internet.url()), + ).rejects.toThrow(EXPECTED_DOWNLOAD_ERROR_MESSAGE); }); - describe('without encryption', () => { - beforeAll(() => { - jest.spyOn(pgpConfigService, 'encrypt', 'get').mockReturnValue(false); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); + it('should download json like data', async () => { + const data = { + string: faker.string.sample(), + number: faker.number.float(), + bool: false, + null: null, + }; - afterAll(() => { - jest.spyOn(pgpConfigService, 'encrypt', 'get').mockReturnValue(true); - }); + const fileUrl = faker.internet.url(); + spyOnDownloadFile.mockImplementation(async (url) => { + if (url === fileUrl) { + return Buffer.from(JSON.stringify(data)); + } - it('should copy a file from a valid URL to a bucket', async () => { - downloadFileFromUrlSpy.mockResolvedValueOnce(someFileContent); - - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(false); - - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - storageService.minioClient.putObject = jest - .fn() - .mockResolvedValueOnce(true); - - const uploadedFile = await storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - MOCK_FILE_URL, - ); - - expect( - uploadedFile.url.includes( - `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/`, - ), - ).toBeTruthy(); - expect(uploadedFile.hash).toBeDefined(); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `s3${crypto - .createHash('sha1') - .update('some-file-content') - .digest('hex')}.zip`, - someFileContent, - { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain', - }, - ); + throw new Error('File not found'); }); + mockedPgpEncryptionService.maybeDecryptFile.mockImplementationOnce( + async (c) => c, + ); - it('should copy an encrypted file from a valid URL to a bucket', async () => { - downloadFileFromUrlSpy.mockResolvedValueOnce(someFileContent); - - EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(true); - Encryption.build = jest.fn().mockResolvedValue({ - decrypt: jest.fn().mockResolvedValue(someFileContent), - }); - - EncryptionUtils.encrypt = jest - .fn() - .mockResolvedValueOnce('encrypted-file-content'); - - storageService.minioClient.statObject = jest - .fn() - .mockRejectedValueOnce({ code: 'NotFound' }); - - const uploadedFile = await storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - MOCK_FILE_URL, - ); - - expect( - uploadedFile.url.includes( - `http://${s3ConfigService.endpoint}:${s3ConfigService.port}/${s3ConfigService.bucket}/`, - ), - ).toBeTruthy(); - expect(uploadedFile.hash).toBeDefined(); - expect(storageService.minioClient.putObject).toHaveBeenCalledWith( - s3ConfigService.bucket, - `s3${crypto - .createHash('sha1') - .update(someFileContent) - .digest('hex')}.zip`, - someFileContent, - { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain', - }, - ); - }); - }); + const downloadedData = await storageService.downloadJsonLikeData(fileUrl); - it('should handle an invalid URL', async () => { - await expect( - storageService.copyFileFromURLToBucket( - escrowAddress, - chainId, - 'invalid url', - ), - ).rejects.toThrow('File not uploaded'); + expect(downloadedData).toEqual(data); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.ts b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.ts index 46f58d7c49..50f9367076 100644 --- a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.ts @@ -1,37 +1,24 @@ -import { - ChainId, - Encryption, - EncryptionUtils, - EscrowClient, - KVStoreUtils, -} from '@human-protocol/sdk'; -import { HttpStatus, Injectable } from '@nestjs/common'; -import axios from 'axios'; +import { Injectable } from '@nestjs/common'; import * as Minio from 'minio'; -import crypto from 'crypto'; -import { UploadedFile } from '../../common/interfaces/s3'; -import { Web3Service } from '../web3/web3.service'; -import { FortuneFinalResult } from '../../common/interfaces/job-result'; + +import { ContentType } from '../../common/enums'; import { S3ConfigService } from '../../config/s3-config.service'; -import { PGPConfigService } from '../../config/pgp-config.service'; -import { isNotFoundError } from '../../common/errors/minio'; -import { - FileDownloadError, - FileNotFoundError, - InvalidFileUrl, -} from './storage.errors'; import logger from '../../logger'; +import * as httpUtils from '../../utils/http'; + +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; + +import { MinioErrorCodes } from './minio.constants'; @Injectable() export class StorageService { private readonly logger = logger.child({ context: StorageService.name }); - public readonly minioClient: Minio.Client; + private readonly minioClient: Minio.Client; constructor( - public readonly s3ConfigService: S3ConfigService, - public readonly pgpConfigService: PGPConfigService, - private readonly web3Service: Web3Service, + private readonly s3ConfigService: S3ConfigService, + private readonly pgpEncryptionService: PgpEncryptionService, ) { this.minioClient = new Minio.Client({ endPoint: this.s3ConfigService.endpoint, @@ -42,227 +29,85 @@ export class StorageService { }); } - public getUrl(key: string): string { + private getUrl(key: string): string { return `${this.s3ConfigService.useSSL ? 'https' : 'http'}://${ this.s3ConfigService.endpoint }:${this.s3ConfigService.port}/${this.s3ConfigService.bucket}/${key}`; } - private async encryptFile( - escrowAddress: string, - chainId: ChainId, - content: any, - ) { - if (!this.pgpConfigService.encrypt) { - return content; - } - - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); - - const jobLauncherAddress = - await escrowClient.getJobLauncherAddress(escrowAddress); - - const reputationOraclePublicKey = await KVStoreUtils.getPublicKey( - chainId, - signer.address, - ); - const jobLauncherPublicKey = await KVStoreUtils.getPublicKey( - chainId, - jobLauncherAddress, - ); - - if (!reputationOraclePublicKey || !jobLauncherPublicKey) { - throw new Error('Missing public key'); - } - - return await EncryptionUtils.encrypt(content, [ - reputationOraclePublicKey, - jobLauncherPublicKey, - ]); - } - - private async maybeDecryptFile(fileContent: Buffer): Promise { - const contentAsString = fileContent.toString(); - if (!EncryptionUtils.isEncrypted(contentAsString)) { - return fileContent; - } - - const encryption = await Encryption.build( - this.pgpConfigService.privateKey!, - this.pgpConfigService.passphrase, - ); - - const decryptedData = await encryption.decrypt(contentAsString); - - return Buffer.from(decryptedData); - } - - public static isValidUrl(maybeUrl: string): boolean { + private async checkFileExists(key: string): Promise { try { - const url = new URL(maybeUrl); - return ['http:', 'https:'].includes(url.protocol); - } catch (_error) { - return false; - } - } - - public static async downloadFileFromUrl(url: string): Promise { - if (!this.isValidUrl(url)) { - throw new InvalidFileUrl(url); - } - - try { - const { data } = await axios.get(url, { - responseType: 'arraybuffer', - }); - - return Buffer.from(data); + await this.minioClient.statObject(this.s3ConfigService.bucket, key); + return true; } catch (error) { - if (error.response?.status === HttpStatus.NOT_FOUND) { - throw new FileNotFoundError(url); + if (error?.code === MinioErrorCodes.NotFound) { + return false; } - throw new FileDownloadError(url, error.cause || error.message); + this.logger.error('Failed to check if file exists', { + fileKey: key, + error, + }); + throw new Error('Error accessing storage'); } } - public async downloadJsonLikeData(url: string): Promise { + async downloadJsonLikeData(url: string): Promise { try { - let fileContent = await StorageService.downloadFileFromUrl(url); - - fileContent = await this.maybeDecryptFile(fileContent); + let fileContent = await httpUtils.downloadFile(url); - let jsonLikeData = fileContent.toString(); - try { - jsonLikeData = JSON.parse(jsonLikeData); - } catch (_noop) {} + fileContent = + await this.pgpEncryptionService.maybeDecryptFile(fileContent); - return jsonLikeData; + return JSON.parse(fileContent.toString()); } catch (error) { - this.logger.error('Error downloading json like data', { + const errorMessage = 'Error downloading json like data'; + this.logger.error(errorMessage, { error, url, }); - return []; + throw new Error(errorMessage); } } - public async uploadJobSolutions( - escrowAddress: string, - chainId: ChainId, - solutions: FortuneFinalResult[], - ): Promise { - if (!(await this.minioClient.bucketExists(this.s3ConfigService.bucket))) { - throw new Error('Bucket not found'); - } - - try { - const content = await this.encryptFile( - escrowAddress, - chainId, - JSON.stringify(solutions), - ); - - const hash = crypto.createHash('sha1').update(content).digest('hex'); - const key = `${hash}.json`; - - // Check if the file already exists in the bucket - try { - await this.minioClient.statObject(this.s3ConfigService.bucket, key); - this.logger.info('File already exist. Skipping upload', { - fileKey: key, - }); - return { url: this.getUrl(key), hash }; - } catch (error) { - if (!isNotFoundError(error)) { - this.logger.error('Error checking if file exists', { - error, - fileKey: key, - }); - throw new Error('Error accessing storage'); - } - } - - await this.minioClient.putObject( - this.s3ConfigService.bucket, - key, - content, - { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }, - ); + async uploadData( + content: string | Buffer, + fileName: string, + contentType: ContentType, + ): Promise { + const isConfiguredBucketExists = await this.minioClient.bucketExists( + this.s3ConfigService.bucket, + ); - return { url: this.getUrl(key), hash }; - } catch (noop) { - throw new Error('File not uploaded'); + if (!isConfiguredBucketExists) { + throw new Error("Can't find configured bucket"); } - } - /** - * **Copy file from a URL to cloud storage** - * - * @param {string} url - URL of the source file - * @returns {Promise} - Uploaded file with key/hash - */ - public async copyFileFromURLToBucket( - escrowAddress: string, - chainId: ChainId, - url: string, - ): Promise { try { - let fileContent = await StorageService.downloadFileFromUrl(url); - fileContent = await this.maybeDecryptFile(fileContent); - // Encrypt for job launcher - const content = await this.encryptFile( - escrowAddress, - chainId, - fileContent, - ); - - // Upload the encrypted file to the bucket - const hash = crypto.createHash('sha1').update(content).digest('hex'); - const key = `s3${hash}.zip`; + const fileUrl = this.getUrl(fileName); - // Check if the file already exists in the bucket - try { - await this.minioClient.statObject(this.s3ConfigService.bucket, key); - this.logger.info('File already exist. Skipping upload', { - fileKey: key, - }); - return { url: this.getUrl(key), hash }; - } catch (error) { - if (!isNotFoundError(error)) { - this.logger.error('Error checking if file exists', { - error, - fileKey: key, - }); - throw new Error('Error accessing storage'); - } + const isAlreadyUploaded = await this.checkFileExists(fileName); + if (isAlreadyUploaded) { + return fileUrl; } await this.minioClient.putObject( this.s3ConfigService.bucket, - key, + fileName, content, { - 'Content-Type': 'text/plain', + 'Content-Type': contentType, 'Cache-Control': 'no-store', }, ); - return { - url: this.getUrl(key), - hash, - }; + return fileUrl; } catch (error) { - this.logger.error('Error copying file', { + this.logger.error('Failed to upload data', { error, - url, - escrowAddress, - chainId, + fileName, + contentType, }); - throw new Error('File not uploaded'); + throw new Error('Data not uploaded'); } } } diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts index c26c03ab62..dc07bf697f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts @@ -3,13 +3,17 @@ import { Web3ConfigService, Web3Network, } from '../../config/web3-config.service'; -import { - generateEthWallet, - generateTestnetChainId, -} from '../../../test/fixtures/web3'; +import { generateEthWallet } from '../../../test/fixtures/web3'; +import { supportedChainIdsByNetwork } from './web3.service'; const testWallet = generateEthWallet(); +export function generateTestnetChainId() { + return faker.helpers.arrayElement( + supportedChainIdsByNetwork[Web3Network.TESTNET], + ); +} + export const mockWeb3ConfigService: Omit = { privateKey: testWallet.privateKey, operatorAddress: testWallet.address, diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts index ed3d325e59..c5aecfcd78 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts @@ -5,9 +5,7 @@ import { faker } from '@faker-js/faker'; import { WalletWithProvider, Web3Service } from './web3.service'; import { Web3ConfigService } from '../../config/web3-config.service'; -import { generateTestnetChainId } from '../../../test/fixtures/web3'; - -import { mockWeb3ConfigService } from './fixtures'; +import { generateTestnetChainId, mockWeb3ConfigService } from './fixtures'; describe('Web3Service', () => { let web3Service: Web3Service; diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index 4acecc4fec..c511913eac 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -6,7 +6,7 @@ import { Web3Network, } from '../../config/web3-config.service'; -const supportedChainIdsByNetwork = { +export const supportedChainIdsByNetwork = { [Web3Network.MAINNET]: [ChainId.POLYGON, ChainId.BSC_MAINNET], [Web3Network.TESTNET]: [ ChainId.POLYGON_AMOY, diff --git a/packages/apps/reputation-oracle/server/src/modules/webhook/webhook-incoming.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/webhook/webhook-incoming.service.spec.ts index b744a31e1d..9bec7fc113 100644 --- a/packages/apps/reputation-oracle/server/src/modules/webhook/webhook-incoming.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/webhook/webhook-incoming.service.spec.ts @@ -32,6 +32,7 @@ import { S3ConfigService } from '../../config/s3-config.service'; import { PGPConfigService } from '../../config/pgp-config.service'; import { IncomingWebhookError, WebhookErrorMessage } from './webhook.error'; import { EscrowPayoutsBatchRepository } from '../escrow-completion/escrow-payouts-batch.repository'; +import { PgpEncryptionService } from '../encryption/pgp-encryption.service'; describe('WebhookIncomingService', () => { let webhookIncomingService: WebhookIncomingService, @@ -68,9 +69,9 @@ describe('WebhookIncomingService', () => { Web3ConfigService, ServerConfigService, PayoutService, + PgpEncryptionService, ReputationService, HttpService, - StorageService, ReputationConfigService, S3ConfigService, PGPConfigService, @@ -78,6 +79,10 @@ describe('WebhookIncomingService', () => { provide: EscrowCompletionRepository, useValue: createMock(), }, + { + provide: StorageService, + useValue: createMock(), + }, { provide: EscrowPayoutsBatchRepository, useValue: createMock(), diff --git a/packages/apps/reputation-oracle/server/src/utils/format-axios-error.ts b/packages/apps/reputation-oracle/server/src/utils/format-axios-error.ts deleted file mode 100644 index 6c4fb2b45b..0000000000 --- a/packages/apps/reputation-oracle/server/src/utils/format-axios-error.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AxiosError } from 'axios'; - -export function formatAxiosError(error: AxiosError) { - return { - name: error.name, - stack: error.stack, - cause: error.cause, - message: error.message, - }; -} diff --git a/packages/apps/reputation-oracle/server/src/utils/http.spec.ts b/packages/apps/reputation-oracle/server/src/utils/http.spec.ts new file mode 100644 index 0000000000..d34972dcca --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/utils/http.spec.ts @@ -0,0 +1,171 @@ +import { faker } from '@faker-js/faker'; +import axios from 'axios'; +import nock from 'nock'; + +import * as httpUtils from './http'; +import { Readable } from 'stream'; + +describe('HTTP utilities', () => { + describe('formatAxiosError', () => { + it('should properly format AxiosError instance', async () => { + const abortController = new AbortController(); + abortController.abort(); + + let thrownError; + try { + await axios.get(faker.internet.url(), { + signal: abortController.signal, + }); + } catch (error) { + thrownError = error; + } + + const EXPECTED_CAUSE = 'synthetic'; + thrownError.cause = EXPECTED_CAUSE; + + const formattedError = httpUtils.formatAxiosError(thrownError); + const ERROR_NAME = 'CanceledError'; + const EXPECTED_MESSAGE = 'canceled'; + + expect(formattedError).toEqual({ + name: ERROR_NAME, + message: EXPECTED_MESSAGE, + stack: expect.stringMatching(`${ERROR_NAME}: ${EXPECTED_MESSAGE}`), + cause: EXPECTED_CAUSE, + }); + }); + }); + + describe('isValidHttpUrl', () => { + it.each([ + '', + faker.string.alphanumeric(), + faker.internet.domainName(), + faker.internet.protocol(), + faker.internet.ipv4(), + // invalid port + `${faker.internet.url({ appendSlash: false })}:${faker.lorem.word({ length: 4 })}`, + `http://[${faker.internet.domainName()}]/`, + 'https://white space.test/', + `ftp://${faker.internet.domainName()}`, + ])('should return false for invalid http url [%#]', (url) => { + expect(httpUtils.isValidHttpUrl(url)).toBe(false); + }); + + it.each([ + faker.internet.url({ protocol: 'http' }), + faker.internet.url({ protocol: 'https' }), + `http://${faker.internet.ipv4()}`, + `${faker.internet.url({ protocol: 'http' })}:${faker.internet.port()}`, + ])('should return true for valid http url [%#]', (url) => { + expect(httpUtils.isValidHttpUrl(url)).toBe(true); + }); + }); + + describe('downloadFile', () => { + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + nock.restore(); + }); + + it('should throw for invalid url', async () => { + const invalidUrl = faker.internet.domainName(); + + let thrownError; + try { + await httpUtils.downloadFile(invalidUrl); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError.location).toBe(invalidUrl); + expect(thrownError.detail).toBe('Invalid http url'); + }); + + it('should throw if file not found', async () => { + const url = faker.internet.url(); + + const scope = nock(url).get('/').reply(404); + + let thrownError; + try { + await httpUtils.downloadFile(url); + } catch (error) { + thrownError = error; + } + + scope.done(); + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError.location).toBe(url); + expect(thrownError.detail).toBe('File not found'); + }); + + it('should format axios errors', async () => { + const url = faker.internet.url(); + + const ERROR_MESSAGE = faker.lorem.words(); + const scope = nock(url).get('/').replyWithError(ERROR_MESSAGE); + + let thrownError; + try { + await httpUtils.downloadFile(url); + } catch (error) { + thrownError = error; + } + + scope.done(); + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError.location).toBe(url); + expect(thrownError.detail).toEqual({ + name: 'Error', + message: ERROR_MESSAGE, + stack: expect.any(String), + cause: expect.any(Error), + }); + }); + + it('should download file as buffer', async () => { + const content = faker.lorem.paragraph(); + + const url = faker.internet.url(); + + const scope = nock(url) + .get('/') + .reply(200, () => Readable.from(Buffer.from(content))); + + const downloadedFile = await httpUtils.downloadFile(url); + + scope.done(); + expect(downloadedFile).toBeInstanceOf(Buffer); + expect(downloadedFile.toString()).toBe(content); + }); + + it('should download file as stream', async () => { + const content = faker.lorem.paragraph(); + + const url = faker.internet.url(); + + const scope = nock(url) + .get('/') + .reply(200, () => Readable.from(Buffer.from(content))); + + const downloadedFileStream = await httpUtils.downloadFile(url, { + asStream: true, + }); + + scope.done(); + expect(downloadedFileStream).toBeInstanceOf(Readable); + + const chunks = []; + for await (const chunk of downloadedFileStream) { + chunks.push(chunk); + } + const downloadedContent = Buffer.concat(chunks).toString(); + expect(downloadedContent).toEqual(content); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/utils/http.ts b/packages/apps/reputation-oracle/server/src/utils/http.ts new file mode 100644 index 0000000000..343d9924c5 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/utils/http.ts @@ -0,0 +1,75 @@ +import axios, { AxiosError } from 'axios'; +import { Readable } from 'stream'; + +import { BaseError } from '../common/errors/base'; + +export function formatAxiosError(error: AxiosError) { + return { + name: error.name, + stack: error.stack, + cause: error.cause, + message: error.message, + }; +} + +class FileDownloadError extends BaseError { + constructor( + readonly location: string, + readonly detail: unknown, + ) { + super('Failed to download file'); + } +} + +function isValidUrl(maybeUrl: string, protocols?: string[]): boolean { + try { + const url = new URL(maybeUrl); + + if (protocols?.length) { + return protocols.includes(url.protocol.replace(':', '')); + } + + return true; + } catch (_error) { + return false; + } +} + +export function isValidHttpUrl(maybeUrl: string): boolean { + return isValidUrl(maybeUrl, ['http', 'https']); +} + +type DownloadFileOptions = { + asStream?: boolean; +}; +type DownloadedFile = T extends { asStream: true } ? Readable : Buffer; +export async function downloadFile( + url: string, + options?: T, +): Promise> { + if (!isValidHttpUrl(url)) { + throw new FileDownloadError(url, 'Invalid http url'); + } + + const shouldReturnStream = options?.asStream === true; + + try { + const response = await axios.get(url, { + responseType: shouldReturnStream ? 'stream' : 'arraybuffer', + }); + + if (shouldReturnStream) { + return response.data as DownloadedFile; + } + + return Buffer.from(response.data as ArrayBuffer) as DownloadedFile; + } catch (error) { + if (error.response?.status === 404) { + throw new FileDownloadError(url, 'File not found'); + } else if (error instanceof AxiosError) { + throw new FileDownloadError(url, formatAxiosError(error)); + } else { + throw error; + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/utils/manifest.ts b/packages/apps/reputation-oracle/server/src/utils/manifest.ts index 66dbd9b991..206f9bc323 100644 --- a/packages/apps/reputation-oracle/server/src/utils/manifest.ts +++ b/packages/apps/reputation-oracle/server/src/utils/manifest.ts @@ -1,14 +1,8 @@ -import { - AudinoManifest, - CvatManifest, - FortuneManifest, -} from '../common/interfaces/manifest'; +import { JobManifest } from '../common/interfaces/manifest'; import { JobRequestType } from '../common/enums'; import { UnsupportedManifestTypeError } from '../common/errors/manifest'; -export function getRequestType( - manifest: FortuneManifest | CvatManifest | AudinoManifest, -): JobRequestType { +export function getRequestType(manifest: JobManifest): JobRequestType { let requestType: JobRequestType | undefined; if ('requestType' in manifest) { diff --git a/packages/apps/reputation-oracle/server/test/constants.ts b/packages/apps/reputation-oracle/server/test/constants.ts index 56ee9b4167..59a1489c99 100644 --- a/packages/apps/reputation-oracle/server/test/constants.ts +++ b/packages/apps/reputation-oracle/server/test/constants.ts @@ -5,129 +5,19 @@ export const MOCK_FILE_URL = 'http://local.test/some-mocked-file-url'; export const MOCK_WEBHOOK_URL = 'mockedWebhookUrl'; export const MOCK_FILE_HASH = 'mockedFileHash'; export const MOCK_FILE_KEY = 'manifest.json'; -export const MOCK_LABEL = 'contains burnt area'; -export const MOCK_LABEL_NEGATIVE = ''; -export const MOCK_RECORDING_ORACLE_FEE = 5; -export const MOCK_REPUTATION_ORACLE_FEE = 5; -export const MOCK_JOB_LAUNCHER_ADDRESS = - '0xCf88b3f1992458C2f5a229573c768D0E9F70C441'; -export const MOCK_EXCHANGE_ORACLE_ADDRESS = - '0xCf88b3f1992458C2f5a229573c768D0E9F70C441'; -export const MOCK_RECORDING_ORACLE_ADDRESS = - '0xCf88b3f1992458C2f5a229573c768D0E9F70C442'; export const MOCK_REPUTATION_ORACLE_ADDRESS = '0xCf88b3f1992458C2f5a229573c768D0E9F70C443'; export const MOCK_PRIVATE_KEY = 'd334daf65a631f40549cc7de126d5a0016f32a2d00c49f94563f9737f7135e55'; -export const MOCK_PGP_PRIVATE_KEY = ` ------BEGIN PGP PRIVATE KEY BLOCK----- -lQOYBGD1Xl8BCAC1vL3mnVZ2S2Ooz1GF6bkxKZR8G+yYQOITriLZ5YXQQyzTveVl -mYk1mkIaWjFJHQ4tTT1cJe5Og6WV2ycRo5EhHzvXw5bAhdDkLHPQEKyRgIUG8IQC -FjGp13DtiY8P2zNL5eMxGiMTp8xQJ7jC3HVZROqUOujcdLPglfE7b5n/Ao9TBwFO -... -... ------END PGP PRIVATE KEY BLOCK----- -`; - -export const MOCK_PGP_PASSPHRASE = 'secure-passphrase'; -export const MOCK_EMAIL = 'test@example.com'; -export const MOCK_PASSWORD = 'password123'; -export const MOCK_HASHED_PASSWORD = - '$2b$12$Z02o9/Ay7CT0n99icApZYORH8iJI9VGtl3mju7d0c4SdDDujhSzOa'; -export const MOCK_ACCESS_TOKEN = 'access_token'; -export const MOCK_REFRESH_TOKEN = 'refresh_token'; -export const MOCK_ACCESS_TOKEN_HASHED = 'access_token_hashed'; -export const MOCK_REFRESH_TOKEN_HASHED = 'refresh_token_hashed'; -export const MOCK_EXPIRES_IN = 300000; -export const MOCK_SENDGRID_API_KEY = - 'SG.xxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; -export const MOCK_SENDGRID_FROM_EMAIL = 'info@hmt.ai'; -export const MOCK_SENDGRID_FROM_NAME = 'John Doe'; - -export const MOCK_S3_ENDPOINT = 'localhost'; -export const MOCK_S3_PORT = 9000; -export const MOCK_S3_ACCESS_KEY = 'access_key'; -export const MOCK_S3_SECRET_KEY = 'secret_key'; -export const MOCK_S3_BUCKET = 'solution'; -export const MOCK_S3_USE_SSL = false; - -export const MOCK_ENCRYPTION_PRIVATE_KEY = `-----BEGIN PGP PRIVATE KEY BLOCK----- - -xVgEZS6w6BYJKwYBBAHaRw8BAQdAXRzFR1ROwdb4Bu7RKYXcBvJsH6JmBxiT -Zwbnk3KUBiUAAP9N8d16MWV/M+yggH6cTODDCNCDV/Ic012RP0fTI4VEjhFF -zRtKb2IgTGF1bmNoZXIgPGFkbWluQGhtdC5haT7CjAQQFgoAPgWCZS6w6AQL -CQcICZBAfiPaLRaJeAMVCAoEFgACAQIZAQKbAwIeARYhBNvDQnyGS7m0aAs+ -BUB+I9otFol4AACYcAEA/c1peyz3aWsB9NkvOfy/erdqkNAAHfikCKzGRKtD -sKcA/Rk9IHYRBzrvXyXXpFYkeFR1H6dXTUYzoZy8xoFleSIOx10EZS6w6BIK -KwYBBAGXVQEFAQEHQPOpvDe3nptX0ZqcFsUz/K7HHnSSOIn/aGYfrZfKAwQ1 -AwEIBwAA/3fQjUAKIaoDAQTB2Jufw9g1JBybjMXSb3YCWunTB6ZgD5vCeAQY -FggAKgWCZS6w6AmQQH4j2i0WiXgCmwwWIQTbw0J8hku5tGgLPgVAfiPaLRaJ -eAAAYHMBAPI7LdZ8k4lQBvlXjVMV3hlkQGtKp+EXHd3BaT1hpniVAP4wecxi -7jxPB0Thko1w1Ro6ZYsFtlOB52qocYtduLJkCA== -=qV+I ------END PGP PRIVATE KEY BLOCK-----`; - -export const MOCK_ENCRYPTION_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK----- - -xjMEZS6w6BYJKwYBBAHaRw8BAQdAXRzFR1ROwdb4Bu7RKYXcBvJsH6JmBxiT -Zwbnk3KUBiXNG0pvYiBMYXVuY2hlciA8YWRtaW5AaG10LmFpPsKMBBAWCgA+ -BYJlLrDoBAsJBwgJkEB+I9otFol4AxUICgQWAAIBAhkBApsDAh4BFiEE28NC -fIZLubRoCz4FQH4j2i0WiXgAAJhwAQD9zWl7LPdpawH02S85/L96t2qQ0AAd -+KQIrMZEq0OwpwD9GT0gdhEHOu9fJdekViR4VHUfp1dNRjOhnLzGgWV5Ig7O -OARlLrDoEgorBgEEAZdVAQUBAQdA86m8N7eem1fRmpwWxTP8rscedJI4if9o -Zh+tl8oDBDUDAQgHwngEGBYIACoFgmUusOgJkEB+I9otFol4ApsMFiEE28NC -fIZLubRoCz4FQH4j2i0WiXgAAGBzAQDyOy3WfJOJUAb5V41TFd4ZZEBrSqfh -Fx3dwWk9YaZ4lQD+MHnMYu48TwdE4ZKNcNUaOmWLBbZTgedqqHGLXbiyZAg= -=IMAe ------END PGP PUBLIC KEY BLOCK-----`; export const MOCK_MAX_RETRY_COUNT = 5; -export const MOCK_BUCKET_FILE = - 'https://bucket.s3.eu-central-1.amazonaws.com/folder/test'; - -export const MOCK_HCAPTCHA_SITE_KEY = 'site-key'; -export const MOCK_HCAPTCHA_API_KEY = 'api-key'; -export const MOCK_HCAPTCHA_TOKEN = 'test-token'; -export const MOCK_HCAPTCHA_SECRET = 'secret'; -export const MOCK_HCAPTCHA_DEFAULT_LABELER_LANG = 'en'; -export const MOCK_HCAPTCHA_PROTECTION_URL = 'https://api.hcaptcha.com'; -export const MOCK_HCAPTCHA_LABELING_URL = 'https://foundation-accounts.hmt.ai'; -export const MOCK_WEB3_RPC_URL = 'http://localhost:8545'; -export const MOCK_QUALIFICATION_MIN_VALIDITY = 100; -export const MOCK_FE_URL = 'http://localhost:3001'; -export const MOCK_KYC_API_PRIVATE_KEY = 'api-private-key'; -export const MOCK_NDA_URL = 'https://staging.humanprotocol.org/nda'; export const mockConfig: any = { - S3_ACCESS_KEY: MOCK_S3_ACCESS_KEY, - S3_SECRET_KEY: MOCK_S3_SECRET_KEY, - S3_ENDPOINT: MOCK_S3_ENDPOINT, - S3_PORT: MOCK_S3_PORT, - S3_USE_SSL: MOCK_S3_USE_SSL, - S3_BUCKET: MOCK_S3_BUCKET, - PGP_ENCRYPT: false, - PGP_PRIVATE_KEY: MOCK_PGP_PRIVATE_KEY, - PGP_PASSPHRASE: MOCK_PGP_PASSPHRASE, WEB3_PRIVATE_KEY: MOCK_PRIVATE_KEY, REPUTATION_LEVEL_LOW: 300, REPUTATION_LEVEL_HIGH: 700, - JWT_ACCESS_TOKEN_EXPIRES_IN: MOCK_EXPIRES_IN, MAX_RETRY_COUNT: MOCK_MAX_RETRY_COUNT, - RPC_URL_POLYGON_AMOY: MOCK_WEB3_RPC_URL, - SENDGRID_API_KEY: MOCK_SENDGRID_API_KEY, - SENDGRID_FROM_EMAIL: MOCK_SENDGRID_FROM_EMAIL, - SENDGRID_FROM_NAME: MOCK_SENDGRID_FROM_NAME, - WEB3_ENV: 'testnet', QUALIFICATION_MIN_VALIDITY: 1, - FE_URL: MOCK_FE_URL, - HCAPTCHA_SITE_KEY: MOCK_HCAPTCHA_SITE_KEY, - HCAPTCHA_API_KEY: MOCK_HCAPTCHA_API_KEY, - HCAPTCHA_SECRET: MOCK_HCAPTCHA_SECRET, - HCAPTCHA_PROTECTION_URL: MOCK_HCAPTCHA_PROTECTION_URL, - HCAPTCHA_LABELING_URL: MOCK_HCAPTCHA_LABELING_URL, - HCAPTCHA_DEFAULT_LABELER_LANG: MOCK_HCAPTCHA_DEFAULT_LABELER_LANG, - KYC_API_PRIVATE_KEY: MOCK_KYC_API_PRIVATE_KEY, - NDA_URL: MOCK_NDA_URL, }; export const MOCK_BACKOFF_INTERVAL_SECONDS = 120; diff --git a/packages/apps/reputation-oracle/server/test/fixtures/web3.ts b/packages/apps/reputation-oracle/server/test/fixtures/web3.ts index 16075d6505..e7b52cff85 100644 --- a/packages/apps/reputation-oracle/server/test/fixtures/web3.ts +++ b/packages/apps/reputation-oracle/server/test/fixtures/web3.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker'; -import { ChainId } from '@human-protocol/sdk'; import { ethers, Wallet } from 'ethers'; export const TEST_PRIVATE_KEY = @@ -23,11 +22,3 @@ export function generateContractAddress() { nonce: faker.number.bigInt(), }); } - -export function generateTestnetChainId() { - return faker.helpers.arrayElement([ - ChainId.BSC_TESTNET, - ChainId.POLYGON_AMOY, - ChainId.SEPOLIA, - ]); -} diff --git a/packages/examples/cvat/exchange-oracle/README.md b/packages/examples/cvat/exchange-oracle/README.md index 11ba79973b..639423301d 100644 --- a/packages/examples/cvat/exchange-oracle/README.md +++ b/packages/examples/cvat/exchange-oracle/README.md @@ -19,14 +19,14 @@ For deployment it is required to have PostgreSQL(v14.4) ### Run the oracle locally: ``` -docker-compose -f docker-compose.dev.yml up -d +docker compose -f docker-compose.dev.yml up -d ./bin/start_dev.sh ``` or ``` -docker-compose -f docker-compose.dev.yml up -d +docker compose -f docker-compose.dev.yml up -d ./bin/start_debug.sh ``` @@ -73,5 +73,5 @@ Available at `/docs` route To run tests ``` -docker-compose -f docker-compose.test.yml up --build test --attach test --exit-code-from test +docker compose -f docker-compose.test.yml up --build test --attach test --exit-code-from test ``` \ No newline at end of file diff --git a/packages/examples/cvat/exchange-oracle/docker-compose.test.yml b/packages/examples/cvat/exchange-oracle/docker-compose.test.yml index 7dd4c273c1..b0008b65e7 100644 --- a/packages/examples/cvat/exchange-oracle/docker-compose.test.yml +++ b/packages/examples/cvat/exchange-oracle/docker-compose.test.yml @@ -81,9 +81,9 @@ services: STORAGE_ACCESS_KEY: 'dev' STORAGE_SECRET_KEY: 'devdevdev' STORAGE_RESULTS_BUCKET_NAME: 'results' - STORAGE_USE_SSL: False + STORAGE_USE_SSL: 'False' STORAGE_PROVIDER: 'aws' - ENABLE_CUSTOM_CLOUD_HOST: Yes + ENABLE_CUSTOM_CLOUD_HOST: 'Yes' REDIS_HOST: 'redis' depends_on: postgres: diff --git a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py index 3d99d5e2cd..2263045119 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py @@ -92,7 +92,12 @@ def handle_job_launcher_event(webhook: Webhook, *, db_session: Session, logger: db_session, webhook.escrow_address ) - cleanup_escrow(webhook.escrow_address, Networks(webhook.chain_id), projects) + cleanup_escrow( + webhook.escrow_address, + Networks(webhook.chain_id), + projects=projects, + session=db_session, + ) cvat_db_service.delete_projects( db_session, webhook.escrow_address, webhook.chain_id ) @@ -139,7 +144,12 @@ def handle_job_launcher_event(webhook: Webhook, *, db_session: Session, logger: cvat_db_service.update_project_statuses_by_escrow_address( db_session, webhook.escrow_address, webhook.chain_id, ProjectStatuses.canceled ) - cleanup_escrow(webhook.escrow_address, Networks(webhook.chain_id), projects) + cleanup_escrow( + webhook.escrow_address, + Networks(webhook.chain_id), + projects=projects, + session=db_session, + ) oracle_db_service.outbox.create_webhook( session=db_session, diff --git a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/reputation_oracle.py b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/reputation_oracle.py index 310b204560..dd1e3e3aec 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/reputation_oracle.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/reputation_oracle.py @@ -29,7 +29,12 @@ def process_incoming_reputation_oracle_webhooks(logger: logging.Logger, session: projects = db_service.get_projects_by_escrow_address( session, webhook.escrow_address ) - cleanup_escrow(webhook.escrow_address, Networks(webhook.chain_id), projects) + cleanup_escrow( + webhook.escrow_address, + Networks(webhook.chain_id), + projects=projects, + session=session, + ) db_service.update_project_statuses_by_escrow_address( session, diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py b/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py index fcacbf5faf..f4cdb1a026 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py @@ -11,11 +11,14 @@ from src.core.config import Config from src.core.storage import compose_data_bucket_prefix, compose_results_bucket_prefix from src.log import get_logger_name +from src.services import cvat as cvat_db_service from src.services.cloud.utils import BucketAccessInfo if TYPE_CHECKING: from collections.abc import Generator + from sqlalchemy.orm import Session + from src.models.cvat import Project logger = logging.getLogger(get_logger_name(__name__)) @@ -47,7 +50,7 @@ def _cleanup_cvat(projects: list[Project]) -> None: if project.cvat_id is not None: with ( _log_error( - errors, f"Encountered error while deliting CVAT project {project.cvat_id}" + errors, f"Encountered error while deleting CVAT project {project.cvat_id}" ), contextlib.suppress(NotFoundException), ): @@ -80,7 +83,15 @@ def _cleanup_storage(escrow_address: str, chain_id: int) -> None: ) -def cleanup_escrow(escrow_address: str, chain_id: int, projects: list[Project]) -> None: +def _cleanup_db(session: Session, escrow_address: str, chain_id: int) -> None: + cvat_db_service.remove_escrow_images( + session=session, escrow_address=escrow_address, chain_id=chain_id + ) + + +def cleanup_escrow( + escrow_address: str, chain_id: int, projects: list[Project], session: Session +) -> None: """ Cleans up CVAT resources and storage related to the given escrow. """ @@ -90,3 +101,4 @@ def cleanup_escrow(escrow_address: str, chain_id: int, projects: list[Project]) # in case both _cleanup_cvat and _cleanup_storage raise an exception, # both will be in the traceback _cleanup_storage(escrow_address, chain_id) + _cleanup_db(session, escrow_address, chain_id) diff --git a/packages/examples/cvat/exchange-oracle/src/services/cvat.py b/packages/examples/cvat/exchange-oracle/src/services/cvat.py index 4ba663917d..ca0e980c1e 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cvat.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cvat.py @@ -1027,6 +1027,18 @@ def get_project_images( ) +def remove_escrow_images(session: Session, escrow_address: str, chain_id: int) -> int: + return ( + session.query(Image) + .where( + Image.project.has( + (Project.escrow_address == escrow_address) & (Project.chain_id == chain_id) + ) + ) + .delete() + ) + + def touch( session: Session, cls: type["Base"], diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py index 3a3755d1a1..e1888c4510 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py @@ -23,7 +23,7 @@ ) from src.cvat.api_calls import RequestStatus from src.db import SessionLocal -from src.models.cvat import EscrowCreation, Project +from src.models.cvat import EscrowCreation, Image, Project from src.models.webhook import Webhook from src.services.cloud import StorageClient from src.services.webhook import OracleWebhookDirectionTags @@ -301,6 +301,16 @@ def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): ) self.session.add(cvat_project) + project_images = [ + Image( + id=str(uuid.uuid4()), + cvat_project_id=cvat_project.cvat_id, + filename=f"image_{i}.jpg", + ) + for i in range(3) + ] + self.session.add_all(project_images) + webhok_id = str(uuid.uuid4()) webhook = Webhook( id=webhok_id, @@ -316,10 +326,15 @@ def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): self.session.add(webhook) self.session.commit() + from src.services.cvat import remove_escrow_images as original_remove_escrow_images + mock_storage_client = MagicMock(spec=StorageClient) with ( patch("src.chain.escrow.get_escrow") as mock_escrow, patch("src.services.cloud.make_client", return_value=mock_storage_client), + patch( + "src.services.cvat.remove_escrow_images", side_effect=original_remove_escrow_images + ) as remove_escrow_images_mock, patch("src.cvat.api_calls.delete_project") as delete_project_mock, patch("src.cvat.api_calls.delete_cloudstorage") as delete_cloudstorage_mock, ): @@ -349,11 +364,27 @@ def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): call(prefix=compose_results_bucket_prefix(escrow_address, chain_id)), ] - assert delete_project_mock.mock_calls == [ - call(1), - ] + assert delete_project_mock.mock_calls == [call(1)] assert delete_cloudstorage_mock.mock_calls == [call(1)] + assert len(remove_escrow_images_mock.mock_calls) == 1 + assert "session" in remove_escrow_images_mock.mock_calls[0].kwargs + assert { + k: v + for k, v in remove_escrow_images_mock.mock_calls[0].kwargs.items() + if k in ("escrow_address", "chain_id") + } == {"escrow_address": escrow_address, "chain_id": chain_id} + assert ( + self.session.query(Image) + .where( + Image.project.has( + (Project.escrow_address == escrow_address) & (Project.chain_id == chain_id) + ) + ) + .count() + == 0 + ) + outgoing_webhooks: list[Webhook] = list( self.session.scalars( select(Webhook).where(Webhook.direction == OracleWebhookDirectionTags.outgoing) diff --git a/scripts/cvat/env-files/.env.reputation-oracle b/scripts/cvat/env-files/.env.reputation-oracle index 4799bce347..e597c5c08e 100644 --- a/scripts/cvat/env-files/.env.reputation-oracle +++ b/scripts/cvat/env-files/.env.reputation-oracle @@ -49,3 +49,7 @@ KYC_API_KEY=disabled KYC_API_PRIVATE_KEY=none NDA_URL=https://humanprotocol.org + +ABUSE_SLACK_WEBHOOK_URL=http://disabled-for-local.app +ABUSE_SLACK_SIGNING_SECRET=disabled +ABUSE_SLACK_OAUTH_TOKEN=disabled \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 259aa91610..fab0f0f14c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -115,6 +115,17 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" +"@asamuzakjp/css-color@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-3.1.1.tgz#41a612834dafd9353b89855b37baa8a03fb67bf2" + integrity sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA== + dependencies: + "@csstools/css-calc" "^2.1.2" + "@csstools/css-color-parser" "^3.0.8" + "@csstools/css-parser-algorithms" "^3.0.4" + "@csstools/css-tokenizer" "^3.0.3" + lru-cache "^10.4.3" + "@automapper/classes@^8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@automapper/classes/-/classes-8.8.1.tgz#ce7ecea0004096f6d96f0841074150e6317ce639" @@ -1698,11 +1709,21 @@ resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.1.tgz#829f1c76f5800b79c51c709e2f36821b728e0e10" integrity sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA== +"@csstools/color-helpers@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.2.tgz#82592c9a7c2b83c293d9161894e2a6471feb97b8" + integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA== + "@csstools/css-calc@^2.1.1": version "2.1.1" resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.1.tgz#a7dbc66627f5cf458d42aed14bda0d3860562383" integrity sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag== +"@csstools/css-calc@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.2.tgz#bffd55f002dab119b76d4023f95cd943e6c8c11e" + integrity sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw== + "@csstools/css-color-parser@^3.0.7": version "3.0.7" resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz#442d61d58e54ad258d52c309a787fceb33906484" @@ -1711,6 +1732,14 @@ "@csstools/color-helpers" "^5.0.1" "@csstools/css-calc" "^2.1.1" +"@csstools/css-color-parser@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.8.tgz#5fe9322920851450bf5e065c2b0e731b9e165394" + integrity sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ== + dependencies: + "@csstools/color-helpers" "^5.0.2" + "@csstools/css-calc" "^2.1.2" + "@csstools/css-parser-algorithms@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz#74426e93bd1c4dcab3e441f5cc7ba4fb35d94356" @@ -2402,6 +2431,11 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.5.0.tgz#ce254c83706250ca8a5a0e05683608160610dd84" integrity sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw== +"@faker-js/faker@^9.7.0": + version "9.7.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.7.0.tgz#1cf1fecfcad5e2da2332140bf3b5f23cc1c2a7f4" + integrity sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg== + "@fastify/busboy@^2.0.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" @@ -2811,10 +2845,12 @@ "@babel/runtime" "^7.17.9" "@hcaptcha/loader" "^1.2.1" -"@hookform/resolvers@^3.3.4": - version "3.10.0" - resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.10.0.tgz#7bfd18113daca4e57e27e1205b7d5a2d371aa59a" - integrity sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag== +"@hookform/resolvers@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-5.0.1.tgz#0a5e90310149e3ac5b017efcb5beb9bdbb711f38" + integrity sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA== + dependencies: + "@standard-schema/utils" "^0.3.0" "@humanwhocodes/config-array@^0.13.0": version "0.13.0" @@ -3681,6 +3717,19 @@ "@motionone/dom" "^10.16.4" tslib "^2.3.1" +"@mswjs/interceptors@^0.38.1": + version "0.38.1" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.38.1.tgz#97569d0d998280b62e57bb7f92c26f6ea5dabb9b" + integrity sha512-JWLtvwpj2aCk2UvLlSQ12BFF/wBJJd0NhhYf7cz4k+tMhymmLn8ss3irznjWPvOsASV2raqb5EjTw+NRoeVDag== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + jsdom "^26.0.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + "@mui/base@5.0.0-beta.40-0": version "5.0.0-beta.40-0" resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.40-0.tgz#4e211724d3feefa3dd1546952502b87491137e89" @@ -4465,6 +4514,24 @@ lodash "^4.17.21" registry-auth-token "^5.0.3" +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + "@openzeppelin/contracts-upgradeable@^4.9.2": version "4.9.6" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.6.tgz#38b21708a719da647de4bb0e4802ee235a0d24df" @@ -5274,6 +5341,103 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@slack/bolt@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@slack/bolt/-/bolt-4.2.1.tgz#fb60f99e91b30b6a3026cd6f3f7e98e6ff5eda84" + integrity sha512-O+c7i5iZKlxt6ltJAu2BclEoyWuAVkcpir1F3HWCHTez8Pjz0GxwdBzNHR5HDXvOdBT7En1BU0T2L6Ldv++GSg== + dependencies: + "@slack/logger" "^4.0.0" + "@slack/oauth" "^3.0.2" + "@slack/socket-mode" "^2.0.3" + "@slack/types" "^2.13.0" + "@slack/web-api" "^7.8.0" + axios "^1.7.8" + express "^5.0.0" + path-to-regexp "^8.1.0" + raw-body "^3" + tsscmp "^1.0.6" + +"@slack/logger@^4", "@slack/logger@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-4.0.0.tgz#788303ff1840be91bdad7711ef66ca0cbc7073d2" + integrity sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA== + dependencies: + "@types/node" ">=18.0.0" + +"@slack/oauth@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@slack/oauth/-/oauth-3.0.2.tgz#e1b6147d25aefff7782ec8e684f6e9aee6609dc2" + integrity sha512-MdPS8AP9n3u/hBeqRFu+waArJLD/q+wOSZ48ktMTwxQLc6HJyaWPf8soqAyS/b0D6IlvI5TxAdyRyyv3wQ5IVw== + dependencies: + "@slack/logger" "^4" + "@slack/web-api" "^7.8.0" + "@types/jsonwebtoken" "^9" + "@types/node" ">=18" + jsonwebtoken "^9" + lodash.isstring "^4" + +"@slack/socket-mode@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@slack/socket-mode/-/socket-mode-2.0.3.tgz#67a646e72afb5a6d355f02824226982af41a1ad3" + integrity sha512-aY1AhQd3HAgxLYC2Mz47dXtW6asjyYp8bJ24MWalg+qFWPaXj8VBYi+5w3rfGqBW5IxlIhs3vJTEQtIBrqQf5A== + dependencies: + "@slack/logger" "^4" + "@slack/web-api" "^7.8.0" + "@types/node" ">=18" + "@types/ws" "^8" + eventemitter3 "^5" + ws "^8" + +"@slack/types@^2.13.0", "@slack/types@^2.9.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.14.0.tgz#913946b4bcb635dad1d39ceca73699215c38cf6f" + integrity sha512-n0EGm7ENQRxlXbgKSrQZL69grzg1gHLAVd+GlRVQJ1NSORo0FrApR7wql/gaKdu2n4TO83Sq/AmeUOqD60aXUA== + +"@slack/web-api@^7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-7.8.0.tgz#e042f39a01ec6f44dc8f244caa2ab1999674479c" + integrity sha512-d4SdG+6UmGdzWw38a4sN3lF/nTEzsDxhzU13wm10ejOpPehtmRoqBKnPztQUfFiWbNvSb4czkWYJD4kt+5+Fuw== + dependencies: + "@slack/logger" "^4.0.0" + "@slack/types" "^2.9.0" + "@types/node" ">=18.0.0" + "@types/retry" "0.12.0" + axios "^1.7.8" + eventemitter3 "^5.0.1" + form-data "^4.0.0" + is-electron "2.2.2" + is-stream "^2" + p-queue "^6" + p-retry "^4" + retry "^0.13.1" + +"@slack/web-api@^7.9.1": + version "7.9.1" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-7.9.1.tgz#bfbb2050fda3b09a33ab03eb490db39e354fde28" + integrity sha512-qMcb1oWw3Y/KlUIVJhkI8+NcQXq1lNymwf+ewk93ggZsGd6iuz9ObQsOEbvlqlx1J+wd8DmIm3DORGKs0fcKdg== + dependencies: + "@slack/logger" "^4.0.0" + "@slack/types" "^2.9.0" + "@types/node" ">=18.0.0" + "@types/retry" "0.12.0" + axios "^1.8.3" + eventemitter3 "^5.0.1" + form-data "^4.0.0" + is-electron "2.2.2" + is-stream "^2" + p-queue "^6" + p-retry "^4" + retry "^0.13.1" + +"@slack/webhook@^7.0.5": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-7.0.5.tgz#1d782faae59e0d9af06b06a51b94acd7d555e5ce" + integrity sha512-PmbZx89+SmH4zt78FUwe4If8hWX2MAIRmGXjmlF0A8PwyJb/H7CWaQYV6DDlZn1+7Zs6CEytKH0ejEE/idVSDw== + dependencies: + "@slack/types" "^2.9.0" + "@types/node" ">=18.0.0" + axios "^1.8.3" + "@smithy/abort-controller@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.0.1.tgz#7c5e73690c4105ad264c2896bd1ea822450c3819" @@ -5870,6 +6034,11 @@ "@stablelib/random" "^1.0.2" "@stablelib/wipe" "^1.0.1" +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@stripe/react-stripe-js@^3.0.0": version "3.1.1" resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz#78a2575683637f87c965a81cc1e0f626138f20f1" @@ -6526,6 +6695,14 @@ dependencies: "@types/node" "*" +"@types/jsonwebtoken@^9": + version "9.0.9" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz#a4c3a446c0ebaaf467a58398382616f416345fb3" + integrity sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ== + dependencies: + "@types/ms" "*" + "@types/node" "*" + "@types/lodash@^4.17.12", "@types/lodash@^4.17.14": version "4.17.15" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.15.tgz#12d4af0ed17cc7600ce1f9980cec48fc17ad1e89" @@ -6599,6 +6776,13 @@ dependencies: undici-types "~6.19.2" +"@types/node@>=18", "@types/node@>=18.0.0": + version "22.13.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4" + integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw== + dependencies: + undici-types "~6.20.0" + "@types/node@^12.12.54": version "12.20.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" @@ -6733,6 +6917,11 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/secp256k1@^4.0.1": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.6.tgz#d60ba2349a51c2cbc5e816dcd831a42029d376bf" @@ -6841,6 +7030,13 @@ dependencies: "@types/node" "*" +"@types/ws@^8": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5" + integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw== + dependencies: + "@types/node" "*" + "@types/ws@^8.0.0": version "8.5.14" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.14.tgz#93d44b268c9127d96026cf44353725dd9b6c3c21" @@ -7216,6 +7412,16 @@ chai "^5.2.0" tinyrainbow "^2.0.0" +"@vitest/expect@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.1.1.tgz#d64ddfdcf9e877d805e1eee67bd845bf0708c6c2" + integrity sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA== + dependencies: + "@vitest/spy" "3.1.1" + "@vitest/utils" "3.1.1" + chai "^5.2.0" + tinyrainbow "^2.0.0" + "@vitest/mocker@3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.0.9.tgz#75d176745131caf40810d3a3a73491595fce46e6" @@ -7225,6 +7431,15 @@ estree-walker "^3.0.3" magic-string "^0.30.17" +"@vitest/mocker@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.1.1.tgz#7689d99f87498684c71e9fe9defdbd13ffb7f1ac" + integrity sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA== + dependencies: + "@vitest/spy" "3.1.1" + estree-walker "^3.0.3" + magic-string "^0.30.17" + "@vitest/pretty-format@3.0.9", "@vitest/pretty-format@^3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.0.9.tgz#d9c88fe64b4edcdbc88e5bd92c39f9cc8d40930d" @@ -7232,6 +7447,13 @@ dependencies: tinyrainbow "^2.0.0" +"@vitest/pretty-format@3.1.1", "@vitest/pretty-format@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.1.1.tgz#5b4d577771daccfced47baf3bf026ad59b52c283" + integrity sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA== + dependencies: + tinyrainbow "^2.0.0" + "@vitest/runner@3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.0.9.tgz#92b7f37f65825105dbfdc07196b90dd8c20547d8" @@ -7240,6 +7462,14 @@ "@vitest/utils" "3.0.9" pathe "^2.0.3" +"@vitest/runner@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.1.1.tgz#76b598700737089d66c74272b2e1c94ca2891a49" + integrity sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA== + dependencies: + "@vitest/utils" "3.1.1" + pathe "^2.0.3" + "@vitest/snapshot@3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.0.9.tgz#2ab878b3590b2daef1798b645a9d9e72a0eb258d" @@ -7249,6 +7479,15 @@ magic-string "^0.30.17" pathe "^2.0.3" +"@vitest/snapshot@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.1.1.tgz#42b6aa0d0e2b3b48b95a5c76efdcc66a44cb11f3" + integrity sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw== + dependencies: + "@vitest/pretty-format" "3.1.1" + magic-string "^0.30.17" + pathe "^2.0.3" + "@vitest/spy@3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.0.9.tgz#c3e5d47ceff7c1cb9fdfb9b2f168056bbc625534" @@ -7256,6 +7495,13 @@ dependencies: tinyspy "^3.0.2" +"@vitest/spy@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.1.1.tgz#deca0b025e151302ab514f38390fd7777e294837" + integrity sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ== + dependencies: + tinyspy "^3.0.2" + "@vitest/utils@3.0.9": version "3.0.9" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.0.9.tgz#15da261d8cacd6035dc28a8d3ba38ee39545f82b" @@ -7265,6 +7511,15 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" +"@vitest/utils@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.1.1.tgz#2893c30219ab6bdf109f07ce5cd287fe8058438d" + integrity sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg== + dependencies: + "@vitest/pretty-format" "3.1.1" + loupe "^3.1.3" + tinyrainbow "^2.0.0" + "@wagmi/connectors@5.7.3": version "5.7.3" resolved "https://registry.yarnpkg.com/@wagmi/connectors/-/connectors-5.7.3.tgz#0e6d274d4734cbfeb8ad964b63b1edcfade42c63" @@ -8062,6 +8317,14 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -8648,6 +8911,15 @@ axios@^1.1.3, axios@^1.3.4, axios@^1.4.0, axios@^1.6.7, axios@^1.7.2, axios@^1.7 form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.7.8: + version "1.8.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.3.tgz#9ebccd71c98651d547162a018a1a95a4b4ed4de8" + integrity sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axios@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.1.tgz#7c118d2146e9ebac512b7d1128771cdd738d11e3" @@ -8657,6 +8929,15 @@ axios@^1.8.1: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.8.3: + version "1.8.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447" + integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" @@ -8914,6 +9195,21 @@ body-parser@1.20.3, body-parser@^1.20.0, body-parser@^1.20.2, body-parser@^1.20. type-is "~1.6.18" unpipe "1.0.0" +body-parser@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.1.0.tgz#2fd84396259e00fa75648835e2d95703bce8e890" + integrity sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.0" + http-errors "^2.0.0" + iconv-lite "^0.5.2" + on-finished "^2.4.1" + qs "^6.14.0" + raw-body "^3.0.0" + type-is "^2.0.0" + bowser@^2.11.0, bowser@^2.9.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" @@ -9197,7 +9493,7 @@ bytes@3.0.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== -bytes@3.1.2: +bytes@3.1.2, bytes@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -9895,7 +10191,14 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@^1.0.4, content-type@~1.0.4, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" + integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== + dependencies: + safe-buffer "5.2.1" + +content-type@^1.0.4, content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -9920,6 +10223,11 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + cookie@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" @@ -10172,6 +10480,14 @@ cssstyle@^4.1.0: "@asamuzakjp/css-color" "^2.8.2" rrweb-cssom "^0.8.0" +cssstyle@^4.2.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.3.0.tgz#83db22d1aec8eb7e5ecd812b4d14a17fb3dd243d" + integrity sha512-6r0NiY0xizYqfBvWp1G7WXJ06/bZyrk7Dc6PHql82C/pKGUTKu4yAX4Y8JPamb1ob9nBKuxWzCGTRuGwU3yxJQ== + dependencies: + "@asamuzakjp/css-color" "^3.1.1" + rrweb-cssom "^0.8.0" + csstype@3.1.3, csstype@^3.0.2, csstype@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" @@ -10351,6 +10667,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" +debug@4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@4.3.7, debug@~4.3.1, debug@~4.3.2: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -10538,7 +10861,7 @@ destr@^2.0.3: resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449" integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ== -destroy@1.2.0: +destroy@1.2.0, destroy@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== @@ -10816,16 +11139,16 @@ encode-utf8@^1.0.2, encode-utf8@^1.0.3: resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== +encodeurl@^2.0.0, encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -11091,7 +11414,7 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== @@ -11522,7 +11845,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: +etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== @@ -11719,12 +12042,12 @@ eventemitter2@^6.4.9: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125" integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg== -eventemitter3@5.0.1, eventemitter3@^5.0.1: +eventemitter3@5.0.1, eventemitter3@^5, eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== -eventemitter3@^4.0.1: +eventemitter3@^4.0.1, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== @@ -11782,6 +12105,11 @@ expect-type@^1.1.0: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.0.tgz#b52a0a1117260f5a8dcf33aef66365be18c13415" integrity sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA== +expect-type@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" + integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== + expect@^29.0.0, expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -11835,6 +12163,44 @@ express@4.21.2, express@^4.21.0: utils-merge "1.0.1" vary "~1.1.2" +express@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.0.1.tgz#5d359a2550655be33124ecbc7400cd38436457e9" + integrity sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ== + dependencies: + accepts "^2.0.0" + body-parser "^2.0.1" + content-disposition "^1.0.0" + content-type "~1.0.4" + cookie "0.7.1" + cookie-signature "^1.2.1" + debug "4.3.6" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "^2.0.0" + fresh "2.0.0" + http-errors "2.0.0" + merge-descriptors "^2.0.0" + methods "~1.1.2" + mime-types "^3.0.0" + on-finished "2.4.1" + once "1.4.0" + parseurl "~1.3.3" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + router "^2.0.0" + safe-buffer "5.2.1" + send "^1.1.0" + serve-static "^2.1.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "^2.0.0" + utils-merge "1.0.1" + vary "~1.1.2" + extend@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -12046,6 +12412,18 @@ finalhandler@1.3.1: statuses "2.0.1" unpipe "~1.0.0" +finalhandler@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.0.tgz#72306373aa89d05a8242ed569ed86a1bff7c561f" + integrity sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -12157,6 +12535,16 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + formidable@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.2.tgz#207c33fecdecb22044c82ba59d0c63a12fb81d77" @@ -12195,11 +12583,16 @@ fp-ts@^1.0.0: resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.19.5.tgz#3da865e585dfa1fdfd51785417357ac50afc520a" integrity sha512-wDNqTimnzs8QqpldiId9OavWK2NptormjXnRJTQecNjzwfyp6P/8s/zG8e4h3ja3oqkKaY72UlTjQYt/1yXf9A== -fresh@0.5.2: +fresh@0.5.2, fresh@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + fs-extra@11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -13117,7 +13510,7 @@ http-call@^5.2.2: parse-json "^4.0.0" tunnel-agent "^0.6.0" -http-errors@2.0.0: +http-errors@2.0.0, http-errors@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== @@ -13158,7 +13551,7 @@ https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" -https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5: +https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -13202,6 +13595,13 @@ iconv-lite@0.6.3, iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" + integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== + dependencies: + safer-buffer ">= 2.1.2 < 3" + idb-keyval@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.1.tgz#94516d625346d16f56f3b33855da11bfded2db33" @@ -13513,7 +13913,7 @@ is-docker@^3.0.0: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== -is-electron@^2.2.0: +is-electron@2.2.2, is-electron@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.2.tgz#3778902a2044d76de98036f5dc58089ac4d80bb9" integrity sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg== @@ -13604,6 +14004,11 @@ is-nan@^1.3.2: call-bind "^1.0.0" define-properties "^1.1.3" +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + is-number-object@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" @@ -13642,6 +14047,11 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + is-regex@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" @@ -13669,7 +14079,7 @@ is-shared-array-buffer@^1.0.4: dependencies: call-bound "^1.0.3" -is-stream@^2.0.0: +is-stream@^2, is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -14478,6 +14888,33 @@ jsdom@^25.0.1: ws "^8.18.0" xml-name-validator "^5.0.0" +jsdom@^26.0.0: + version "26.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-26.0.0.tgz#446dd1ad8cfc50df7e714e58f1f972c1763b354c" + integrity sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw== + dependencies: + cssstyle "^4.2.1" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.1" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.6" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.16" + parse5 "^7.2.1" + rrweb-cssom "^0.8.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^5.0.0" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.1.0" + ws "^8.18.0" + xml-name-validator "^5.0.0" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" @@ -14622,7 +15059,7 @@ jsonschema@^1.2.4, jsonschema@^1.4.1: resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.5.0.tgz#f6aceb1ab9123563dd901d05f81f9d4883d3b7d8" integrity sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw== -jsonwebtoken@9.0.2, jsonwebtoken@^9.0.0, jsonwebtoken@^9.0.2: +jsonwebtoken@9.0.2, jsonwebtoken@^9, jsonwebtoken@^9.0.0, jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== @@ -14959,7 +15396,7 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== -lodash.isstring@^4.0.1: +lodash.isstring@^4, lodash.isstring@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== @@ -15271,6 +15708,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + memfs@^3.4.1: version "3.6.0" resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6" @@ -15288,6 +15730,11 @@ merge-descriptors@1.0.3: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-options@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7" @@ -15346,6 +15793,11 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== +mime-db@^1.53.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-db@~1.33.0: version "1.33.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" @@ -15365,6 +15817,13 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.24, dependencies: mime-db "1.52.0" +mime-types@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" + integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== + dependencies: + mime-db "^1.53.0" + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -15633,6 +16092,11 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -15760,6 +16224,11 @@ negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -15791,6 +16260,15 @@ nock@^13.5.1: json-stringify-safe "^5.0.1" propagate "^2.0.0" +nock@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/nock/-/nock-14.0.3.tgz#e6a7b4945663af3e345774892f9d2c817535fb8d" + integrity sha512-sJ9RNmCuYBqXDmGZZHgZ1D1441MqFOU4T5aeLGVGEB4OWI/2LM0mZlkfBQzQKdOfJypL+2nPPBugXKjixBn4kQ== + dependencies: + "@mswjs/interceptors" "^0.38.1" + json-stringify-safe "^5.0.1" + propagate "^2.0.0" + node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" @@ -16000,6 +16478,11 @@ nwsapi@^2.2.12, nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.16.tgz#177760bba02c351df1d2644e220c31dfec8cdb43" integrity sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ== +nwsapi@^2.2.16: + version "2.2.20" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.20.tgz#22e53253c61e7b0e7e93cef42c891154bcca11ef" + integrity sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA== + obj-multiplex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/obj-multiplex/-/obj-multiplex-1.0.0.tgz#2f2ae6bfd4ae11befe742ea9ea5b36636eabffc1" @@ -16121,7 +16604,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -16133,7 +16616,7 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@1.x, once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@1.4.0, once@1.x, once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -16264,6 +16747,11 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -16317,6 +16805,11 @@ p-fifo@^1.0.0: fast-fifo "^1.0.0" p-defer "^3.0.0" +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + p-limit@3.1.0, p-limit@^3.0.1, p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -16352,6 +16845,14 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" +p-queue@^6: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + p-queue@^8.0.1: version "8.1.0" resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-8.1.0.tgz#d71929249868b10b16f885d8a82beeaf35d32279" @@ -16360,6 +16861,21 @@ p-queue@^8.0.1: eventemitter3 "^5.0.1" p-timeout "^6.1.2" +p-retry@^4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-timeout@^6.1.2: version "6.1.4" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2" @@ -16439,14 +16955,14 @@ parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parse5@^7.0.0, parse5@^7.1.1, parse5@^7.1.2: +parse5@^7.0.0, parse5@^7.1.1, parse5@^7.1.2, parse5@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a" integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== dependencies: entities "^4.5.0" -parseurl@~1.3.3: +parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -16544,6 +17060,11 @@ path-to-regexp@3.3.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== +path-to-regexp@^8.0.0, path-to-regexp@^8.1.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" + integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -17162,7 +17683,7 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" -qs@^6.11.0, qs@^6.12.3, qs@^6.9.4: +qs@^6.11.0, qs@^6.12.3, qs@^6.14.0, qs@^6.9.4: version "6.14.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== @@ -17233,7 +17754,7 @@ range-parser@1.2.0: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" integrity sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A== -range-parser@~1.2.1: +range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== @@ -17248,6 +17769,16 @@ raw-body@2.5.2, raw-body@^2.4.1: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3, raw-body@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.6.3" + unpipe "1.0.0" + rc@^1.0.1, rc@^1.1.6: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -17271,10 +17802,10 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-hook-form@^7.53.2: - version "7.54.2" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" - integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== +react-hook-form@^7.55.0: + version "7.55.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.55.0.tgz#df3c80a20a68f6811f49bec3406defaefb6dce80" + integrity sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog== react-i18next@^15.1.0: version "15.4.0" @@ -17772,7 +18303,7 @@ retry-request@^7.0.0: extend "^3.0.2" teeny-request "^9.0.0" -retry@0.13.1: +retry@0.13.1, retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== @@ -17849,6 +18380,15 @@ rollup@^4.30.1: "@rollup/rollup-win32-x64-msvc" "4.34.8" fsevents "~2.3.2" +router@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.1.0.tgz#f256ca2365afb4d386ba4f7a9ee0aa0827c962fa" + integrity sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA== + dependencies: + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + rrweb-cssom@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b" @@ -18072,6 +18612,24 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" +send@^1.0.0, send@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" + integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== + dependencies: + debug "^4.3.5" + destroy "^1.2.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^0.5.2" + http-errors "^2.0.0" + mime-types "^2.1.35" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.1" + serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -18102,6 +18660,16 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" +serve-static@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" + integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.0.0" + serve@^14.2.4: version "14.2.4" resolved "https://registry.yarnpkg.com/serve/-/serve-14.2.4.tgz#ba4c425c3c965f496703762e808f34b913f42fb0" @@ -18572,7 +19140,7 @@ stacktrace-parser@^0.1.10: dependencies: type-fest "^0.7.1" -statuses@2.0.1: +statuses@2.0.1, statuses@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== @@ -18582,6 +19150,11 @@ std-env@^3.8.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.1.tgz#2b81c631c62e3d0b964b87f099b8dcab6c9a5346" integrity sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA== +std-env@^3.8.1: + version "3.9.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== + stream-browserify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" @@ -18636,6 +19209,11 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" @@ -19281,6 +19859,13 @@ tr46@^5.0.0: dependencies: punycode "^2.3.1" +tr46@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.0.tgz#4a077922360ae807e172075ce5beb79b36e4a101" + integrity sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw== + dependencies: + punycode "^2.3.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -19427,6 +20012,11 @@ tsort@0.0.1: resolved "https://registry.yarnpkg.com/tsort/-/tsort-0.0.1.tgz#e2280f5e817f8bf4275657fd0f9aebd44f5a2786" integrity sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw== +tsscmp@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -19523,6 +20113,15 @@ type-is@^1.6.4, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +type-is@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" + integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" + typechain@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/typechain/-/typechain-8.3.2.tgz#1090dd8d9c57b6ef2aed3640a516bdbf01b00d73" @@ -19941,6 +20540,11 @@ uuid@9.0.1, uuid@^9.0.0, uuid@^9.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" @@ -20039,7 +20643,21 @@ viem@2.23.2, viem@2.x, viem@^2.1.1, viem@^2.21.44: ox "0.6.7" ws "8.18.0" -viem@2.7.14, viem@>=2.23.11, viem@^2.15.1: +viem@2.7.14, viem@^2.15.1: + version "2.27.0" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.27.0.tgz#edfca8e107d96eecff70d6c4f049c5e43422f902" + integrity sha512-pKw2dcwDi6TaWlTzLHYazOgjO1GgbUpE1zdLsLNSiCjHNrMTpL/teL0wVHnJDLiB2tR5CL19LBqefYNtRUkH5Q== + dependencies: + "@noble/curves" "1.8.1" + "@noble/hashes" "1.7.1" + "@scure/bip32" "1.6.2" + "@scure/bip39" "1.5.4" + abitype "1.0.8" + isows "1.0.6" + ox "0.6.9" + ws "8.18.1" + +viem@>=2.23.11: version "2.26.0" resolved "https://registry.yarnpkg.com/viem/-/viem-2.26.0.tgz#e46aa05212875d4f306449b3bd594e97303abdde" integrity sha512-Osht20EySRAcfMhCGAJaWuaREXM7y9vDloLvUTXuAfFq7JNGM5+o1wsE4LXw1KpRBrBliIAJjM+c2wMtMEvlCQ== @@ -20064,6 +20682,17 @@ vite-node@3.0.9: pathe "^2.0.3" vite "^5.0.0 || ^6.0.0" +vite-node@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.1.1.tgz#ad186c07859a6e5fca7c7f563e55fb11b16557bc" + integrity sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w== + dependencies: + cac "^6.7.14" + debug "^4.4.0" + es-module-lexer "^1.6.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0" + vite-plugin-node-polyfills@^0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.22.0.tgz#d0afcf82eb985fc02244620d7cec1ddd1c6e0864" @@ -20126,6 +20755,32 @@ vitest@^3.0.9: vite-node "3.0.9" why-is-node-running "^2.3.0" +vitest@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.1.1.tgz#39fa2356e510513fccdc5d16465a9fc066ef1fc6" + integrity sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q== + dependencies: + "@vitest/expect" "3.1.1" + "@vitest/mocker" "3.1.1" + "@vitest/pretty-format" "^3.1.1" + "@vitest/runner" "3.1.1" + "@vitest/snapshot" "3.1.1" + "@vitest/spy" "3.1.1" + "@vitest/utils" "3.1.1" + chai "^5.2.0" + debug "^4.4.0" + expect-type "^1.2.0" + magic-string "^0.30.17" + pathe "^2.0.3" + std-env "^3.8.1" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinypool "^1.0.2" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0" + vite-node "3.1.1" + why-is-node-running "^2.3.0" + vm-browserify@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" @@ -20573,6 +21228,14 @@ whatwg-url@^14.0.0: tr46 "^5.0.0" webidl-conversions "^7.0.0" +whatwg-url@^14.1.0: + version "14.2.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== + dependencies: + tr46 "^5.1.0" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -20775,7 +21438,7 @@ ws@8.17.1, ws@~8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== -ws@8.18.1: +ws@8.18.1, ws@^8: version "8.18.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==