diff --git a/cypress/e2e/tests/auth/signup.cy.ts b/cypress/e2e/tests/auth/signup.cy.ts new file mode 100644 index 000000000..0d9c7a32b --- /dev/null +++ b/cypress/e2e/tests/auth/signup.cy.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-undef */ +/// + +import locators from "../../../support/locators"; + +describe("Sign Up tests", () => { + const TIMEOUT = { + DEFAULT: 10000, + API: 30000, + }; + + const testUser = { + name: "Test User", + email: `test${Date.now()}@aletheiafact.org`, + password: "TestPassword123!", + }; + + beforeEach(() => { + cy.clearCookies(); + cy.clearLocalStorage(); + + cy.goToSignUpPage(); + + cy.get('iframe[title="reCAPTCHA"]', { timeout: TIMEOUT.DEFAULT }) + .should("be.visible") + .its("0.contentDocument.body") + .should("not.be.empty"); + }); + + it("Should display sign up form", () => { + cy.get(locators.signup.NAME).should("be.visible"); + cy.get(locators.signup.EMAIL).should("be.visible"); + cy.get(locators.signup.PASSWORD).should("be.visible"); + cy.get(locators.signup.REPEATED_PASSWORD).should("be.visible"); + cy.get('iframe[title="reCAPTCHA"]').should("be.visible"); + cy.get(locators.signup.BTN_SUBMIT).should("be.visible"); + }); + + it("Should show validation error when name is empty", () => { + cy.get(locators.signup.EMAIL).clear().type(testUser.email); + cy.get(locators.signup.PASSWORD).clear().type(testUser.password); + cy.get(locators.signup.REPEATED_PASSWORD) + .clear() + .type(testUser.password); + cy.checkRecaptcha(); + cy.get(locators.signup.BTN_SUBMIT).click(); + + cy.get(locators.signup.ERROR_NAME, { timeout: TIMEOUT.DEFAULT }).should( + "be.visible" + ); + }); + + it("Should show validation error when email is invalid", () => { + cy.get(locators.signup.NAME).clear().type(testUser.name); + cy.get(locators.signup.EMAIL).clear().type("invalid-email"); + cy.get(locators.signup.PASSWORD).clear().type(testUser.password); + cy.get(locators.signup.REPEATED_PASSWORD) + .clear() + .type(testUser.password); + cy.checkRecaptcha(); + cy.get(locators.signup.BTN_SUBMIT).click(); + + cy.get(locators.signup.ERROR_EMAIL, { + timeout: TIMEOUT.DEFAULT, + }).should("be.visible"); + }); + + it("Should show validation error when passwords don't match", () => { + cy.get(locators.signup.NAME).clear().type(testUser.name); + cy.get(locators.signup.EMAIL).clear().type(testUser.email); + cy.get(locators.signup.PASSWORD).clear().type(testUser.password); + cy.get(locators.signup.REPEATED_PASSWORD) + .clear() + .type("DifferentPassword123!"); + cy.checkRecaptcha(); + cy.get(locators.signup.BTN_SUBMIT).click(); + + cy.get(locators.signup.ERROR_REPEATED_PASSWORD, { + timeout: TIMEOUT.DEFAULT, + }).should("be.visible"); + }); + + it("Should show error when CAPTCHA is not completed", () => { + cy.get(locators.signup.NAME).clear().type(testUser.name); + cy.get(locators.signup.EMAIL).clear().type(testUser.email); + cy.get(locators.signup.PASSWORD).clear().type(testUser.password); + cy.get(locators.signup.REPEATED_PASSWORD) + .clear() + .type(testUser.password); + cy.get(locators.signup.BTN_SUBMIT).click(); + + cy.get(".ant-message-error, .MuiAlert-standardError, [role='alert']", { + timeout: TIMEOUT.DEFAULT, + }).should("be.visible"); + }); + + it("Should successfully create account with valid data and CAPTCHA", () => { + cy.get(locators.signup.NAME).clear().type(testUser.name); + cy.get(locators.signup.EMAIL).clear().type(testUser.email); + cy.get(locators.signup.PASSWORD).clear().type(testUser.password); + cy.get(locators.signup.REPEATED_PASSWORD) + .clear() + .type(testUser.password); + cy.checkRecaptcha(); + + cy.intercept("POST", "/api/user/register", (req) => { + req.continue((res) => { + if (res.statusCode >= 400) { + cy.log( + `Registration failed: ${ + res.statusCode + } - ${JSON.stringify(res.body)}` + ); + } + }); + }).as("registerUser"); + cy.intercept("/api/.ory/sessions/whoami").as("confirmLogin"); + + cy.get(locators.signup.BTN_SUBMIT).click(); + + cy.wait("@registerUser", { timeout: TIMEOUT.API }).then( + (interception) => { + const isSuccess = [200, 201].includes( + interception.response.statusCode + ); + if (!isSuccess) { + cy.log( + `Registration API Error: ${interception.response.statusCode}` + ); + cy.log( + `Error body: ${JSON.stringify( + interception.response.body + )}` + ); + expect(interception.response.statusCode).to.be.oneOf([ + 200, 201, + ]); + } + } + ); + + cy.wait("@confirmLogin", { timeout: TIMEOUT.API }); + + cy.url({ timeout: TIMEOUT.API }).should("eq", "http://localhost:3000/"); + cy.get( + ".ant-message-success, .MuiAlert-standardSuccess, [role='alert']", + { timeout: TIMEOUT.DEFAULT } + ).should("exist"); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 15e0b3a42..15efef0f0 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,21 +25,50 @@ Cypress.Commands.add("checkRecaptcha", () => { // get the iframe > document > body // and retries until the body is not empty or fails with timeout return cy - .get('iframe[title="reCAPTCHA"]') + .get('iframe[title="reCAPTCHA"]', { timeout: 10000 }) .its("0.contentDocument.body") .should("not.be.empty") .then(cy.wrap); }; - getIframeBody().find("#recaptcha-anchor").click(); + getIframeBody() + .find("#recaptcha-anchor", { timeout: 10000 }) + .should("be.visible") + .click({ force: true }); + + cy.wait(500); +}); + +Cypress.Commands.add("goToSignUpPage", () => { + cy.visit("http://localhost:3000/sign-up"); + cy.url().should("contains", "sign-up"); }); +Cypress.Commands.add( + "signup", + (name: string, email: string, password: string) => { + cy.goToSignUpPage(); + cy.get(locators.signup.NAME).type(name); + cy.get(locators.signup.EMAIL).type(email); + cy.get(locators.signup.PASSWORD).type(password); + cy.get(locators.signup.REPEATED_PASSWORD).type(password); + cy.checkRecaptcha(); + cy.get(locators.signup.BTN_SUBMIT).click(); + } +); + declare global { namespace Cypress { interface Chainable { login(): Chainable; checkRecaptcha(): Chainable; goToLoginPage(): Chainable; + goToSignUpPage(): Chainable; + signup( + name: string, + email: string, + password: string + ): Chainable; } } } diff --git a/cypress/support/locators.ts b/cypress/support/locators.ts index d836c6549..d71c356e1 100644 --- a/cypress/support/locators.ts +++ b/cypress/support/locators.ts @@ -7,6 +7,18 @@ const locators = { BTN_LOGIN: "[data-cy=loginButton]", }, + signup: { + NAME: "[data-cy=nameInputCreateAccount]", + EMAIL: "[data-cy=emailInputCreateAccount]", + PASSWORD: "[data-cy=passwordInputCreateAccount]", + REPEATED_PASSWORD: "[data-cy=repeatedPasswordInputCreateAccount]", + BTN_SUBMIT: "[data-cy=loginButton]", + ERROR_NAME: "[data-cy=nameError]", + ERROR_EMAIL: "[data-cy=emailError]", + ERROR_PASSWORD: "[data-cy=passwordError]", + ERROR_REPEATED_PASSWORD: "[data-cy=repeatedPasswordError]", + }, + personality: { BTN_SEE_MORE_PERSONALITY: "[data-cy=testSeeMorePersonality]", BTN_ADD_PERSONALITY: "[data-cy=testButtonCreatePersonality]", diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 15353f241..68f11a035 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "types": ["cypress"] + "target": "es2017", + "lib": ["es2017", "dom"], + "types": ["cypress"], + "moduleResolution": "node", + "esModuleInterop": true }, "include": ["**/*.ts"] } \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ddaa71654..d94f13d6e 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -12,6 +12,7 @@ "supportEmail": "support@aletheifact.org", "contactEmail": "contact@aletheiafact.org", "captchaError": "There was an error validating the captcha", + "captchaLabel": "Verification", "change": "Change", "approve": "Approve", "reject": "Reject" diff --git a/public/locales/en/login.json b/public/locales/en/login.json index 6ebecabb2..7c94e85a7 100644 --- a/public/locales/en/login.json +++ b/public/locales/en/login.json @@ -7,6 +7,7 @@ "nameLabel": "Name", "submitButton": "Submit", "emailErrorMessage": "Please, insert your e-mail", + "invalidEmailErrorMessage": "Invalid e-mail", "nameErrorMessage": "Please, insert your name", "passwordErrorMessage": "Please, insert your password", "loginFailedMessage": "Error while logging in", diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json index b610af36e..cd67f5176 100644 --- a/public/locales/pt/common.json +++ b/public/locales/pt/common.json @@ -12,6 +12,7 @@ "supportEmail": "support@aletheifact.org", "contactEmail": "contato@aletheiafact.org", "captchaError": "Erro na validação do captcha", + "captchaLabel": "Verificação", "change": "Mudar", "approve": "Aprovar", "reject": "Rejeitar" diff --git a/server/users/dto/create-user.dto.ts b/server/users/dto/create-user.dto.ts index 280b04cd5..fed2c53c2 100644 --- a/server/users/dto/create-user.dto.ts +++ b/server/users/dto/create-user.dto.ts @@ -16,4 +16,9 @@ export class CreateUserDTO { @IsString() @ApiProperty() password: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + recaptcha: string; } diff --git a/server/users/users.controller.ts b/server/users/users.controller.ts index 86ca04ca7..af0d60bcf 100644 --- a/server/users/users.controller.ts +++ b/server/users/users.controller.ts @@ -25,6 +25,7 @@ import { ApiTags } from "@nestjs/swagger"; import { UtilService } from "../util"; import { GetUsersDTO } from "./dto/get-users.dto"; import { Public, AdminOnly, Auth } from "../auth/decorators/auth.decorator"; +import { CaptchaService } from "../captcha/captcha.service"; // TODO: check permissions for routes @Controller(":namespace?") @@ -33,7 +34,8 @@ export class UsersController { private readonly usersService: UsersService, private viewService: ViewService, private configService: ConfigService, - private util: UtilService + private readonly util: UtilService, + private readonly captchaService: CaptchaService ) {} @ApiTags("pages") @@ -52,11 +54,12 @@ export class UsersController { @Public() public async signUp(@Req() req: Request, @Res() res: Response) { const parsedUrl = parse(req.url, true); + const sitekey = this.configService.get("recaptcha_sitekey"); await this.viewService.render( req, res, "/sign-up", - Object.assign(parsedUrl.query) + Object.assign(parsedUrl.query, { sitekey }) ); } @@ -64,6 +67,13 @@ export class UsersController { @Post("api/user/register") @Public() public async register(@Body() createUserDto: CreateUserDTO) { + const validateCaptcha = await this.captchaService.validate( + createUserDto.recaptcha + ); + if (!validateCaptcha) { + throw new UnprocessableEntityException("Error validating captcha"); + } + try { return await this.usersService.register(createUserDto); } catch (errorResponse) { diff --git a/server/users/users.module.ts b/server/users/users.module.ts index 1cf6bafa4..41b21683c 100644 --- a/server/users/users.module.ts +++ b/server/users/users.module.ts @@ -11,6 +11,7 @@ import { UtilService } from "../util"; import { NotificationModule } from "../notifications/notifications.module"; import { SessionGuard } from "../auth/session.guard"; import { M2MGuard } from "../auth/m2m.guard"; +import { CaptchaModule } from "../captcha/captcha.module"; const UserModel = MongooseModule.forFeature([ { @@ -26,7 +27,8 @@ const UserModel = MongooseModule.forFeature([ OryModule, ConfigModule, AbilityModule, - NotificationModule + NotificationModule, + CaptchaModule, ], exports: [UsersService, UserModel], controllers: [UsersController], diff --git a/src/components/Login/LoginView.tsx b/src/components/Login/LoginView.tsx index 5fd68cd5a..da624b283 100644 --- a/src/components/Login/LoginView.tsx +++ b/src/components/Login/LoginView.tsx @@ -74,7 +74,10 @@ const LoginView = ({ isSignUp = false, shouldGoBack = false }) => { if (err.response?.status === 400) { // Yup, it is! setFlow(err.response?.data); - return MessageManager.showMessage("error", `${t("profile:totpIncorectCodeMessage")}`); + return MessageManager.showMessage( + "error", + `${t("profile:totpIncorectCodeMessage")}` + ); } return Promise.reject(err); @@ -122,6 +125,7 @@ const LoginView = ({ isSignUp = false, shouldGoBack = false }) => { email, password, name: values.name, + recaptcha: values.recaptcha, }; userApi.register(payload, t).then((res) => { if (!res?.error) { @@ -143,15 +147,19 @@ const LoginView = ({ isSignUp = false, shouldGoBack = false }) => { const onFinishFailed = (errorInfo) => { if (typeof errorInfo === "string") { - MessageManager.showMessage("error", errorInfo) + MessageManager.showMessage("error", errorInfo); } else { - MessageManager.showMessage("error", `${t("login:loginFailedMessage")}`); + MessageManager.showMessage( + "error", + `${t("login:loginFailedMessage")}` + ); } setIsLoading(false); }; return ( - @@ -176,7 +184,8 @@ const LoginView = ({ isSignUp = false, shouldGoBack = false }) => { + textWhenLoggedOut={t("CTAFolder:button")} + /> > )} diff --git a/src/components/Login/SignUpForm.tsx b/src/components/Login/SignUpForm.tsx index 557fd3d76..0d8a3f659 100644 --- a/src/components/Login/SignUpForm.tsx +++ b/src/components/Login/SignUpForm.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "next-i18next"; -import React from "react"; +import React, { useRef, useState } from "react"; import AletheiaAlert from "../AletheiaAlert"; import Input from "../AletheiaInput"; @@ -9,6 +9,7 @@ import { Grid } from "@mui/material"; import { useForm } from "react-hook-form"; import Label from "../Label"; import TextError from "../TextErrorForm"; +import AletheiaCaptcha from "../AletheiaCaptcha"; const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { const { t } = useTranslation(); @@ -19,6 +20,16 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { formState: { errors }, } = useForm(); const senha = watch("password"); + const captchaRef = useRef(null); + const [captchaString, setCaptchaString] = useState(""); + + const handleFormSubmit = (values) => { + if (!captchaString) { + onFinishFailed(t("common:requiredFieldError")); + return; + } + onFinish({ ...values, recaptcha: captchaString }); + }; return ( @@ -37,10 +48,11 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { } /> {t("login:signupFormHeader")} - + - @@ -48,16 +60,18 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { - @@ -66,10 +80,12 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { data-cy="emailInputCreateAccount" {...register("email", { required: true, - pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ + pattern: + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, })} /> { /> - @@ -87,16 +104,18 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { - @@ -105,16 +124,28 @@ const SignUpForm = ({ onFinish, onFinishFailed, isLoading }) => { data-cy="repeatedPasswordInputCreateAccount" {...register("repeatedPassword", { required: true, - validate: (value) => - value === senha + validate: (value) => value === senha, })} /> + + + + {t("common:captchaLabel") + " :"} + + + + diff --git a/src/components/TextErrorForm.tsx b/src/components/TextErrorForm.tsx index e18bbf45d..6b8736778 100644 --- a/src/components/TextErrorForm.tsx +++ b/src/components/TextErrorForm.tsx @@ -1,19 +1,20 @@ import React from "react"; import colors from "../styles/colors"; -const TextError = ({ children, stateError }) => { - return ( - - {children} - - ); +const TextError = ({ children, stateError, "data-cy": dataCy }) => { + return ( + + {children} + + ); }; export default TextError; diff --git a/src/pages/sign-up.tsx b/src/pages/sign-up.tsx index 8b4d2c196..293ab79d5 100644 --- a/src/pages/sign-up.tsx +++ b/src/pages/sign-up.tsx @@ -1,13 +1,18 @@ import { NextPage } from "next"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useDispatch } from "react-redux"; import LoginView from "../components/Login/LoginView"; +import actions from "../store/actions"; import Seo from "../components/Seo"; import { GetLocale } from "../utils/GetLocale"; -const SignUpPage: NextPage = () => { +const SignUpPage: NextPage<{ sitekey: string }> = ({ sitekey }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); + dispatch(actions.setSitekey(sitekey)); + return ( <> { ); }; -export async function getServerSideProps({ locale, locales, req }) { +export async function getServerSideProps({ locale, locales, req, query }) { locale = GetLocale(req, locale, locales); + const sitekey = + process.env.NEXT_PUBLIC_RECAPTCHA_SITEKEY || query.sitekey || ""; + return { props: { ...(await serverSideTranslations(locale)), + sitekey, }, }; }
- {children} -
+ {children} +