From 4a0204d0ab3648556d311a6350d8300f094e0a42 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Tue, 28 Jul 2026 23:45:08 -0300 Subject: [PATCH 1/7] feat: wrap LandingPage in PublicRoute for authentication handling --- frontend/src/app/AppRoutes.tsx | 9 ++++++++- frontend/tests/unit/app/AppRoutes.test.tsx | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index f60d67b..9a7e28d 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -34,7 +34,14 @@ export function AppRoutes() { return ( - } /> + + + + } + /> diff --git a/frontend/tests/unit/app/AppRoutes.test.tsx b/frontend/tests/unit/app/AppRoutes.test.tsx index 0a8a4b7..55ae855 100644 --- a/frontend/tests/unit/app/AppRoutes.test.tsx +++ b/frontend/tests/unit/app/AppRoutes.test.tsx @@ -73,6 +73,19 @@ describe("AppRoutes", () => { expect(screen.getByText("Landing route")).toBeInTheDocument(); }); + it("redireciona usuário autenticado da rota inicial para home", () => { + authState.value = { + user: { id: "1", email: "otavio@example.com" }, + isLoading: false, + }; + + renderRoute("/"); + + expect(screen.getByText("Dashboard shell")).toBeInTheDocument(); + expect(screen.getByText("New dashboard route")).toBeInTheDocument(); + expect(screen.queryByText("Landing route")).not.toBeInTheDocument(); + }); + it("mostra loading em rota protegida enquanto a sessão está carregando", () => { authState.value = { user: null, From 64fa516ad313f7a354e03579a923dc5fcc261662 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Wed, 29 Jul 2026 00:00:29 -0300 Subject: [PATCH 2/7] =?UTF-8?q?fix(cadastro):=20limitar=20campos=20e=20ali?= =?UTF-8?q?nhar=20valida=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/types/credentials.types.ts | 34 ++++- .../modules/auth/credentials.service.test.ts | 34 +++-- .../components/RegisterFormPanel.tsx | 30 ++++- .../components/login/RegisterSide.test.tsx | 116 ++++++++++++++---- 4 files changed, 170 insertions(+), 44 deletions(-) diff --git a/backend/src/modules/types/credentials.types.ts b/backend/src/modules/types/credentials.types.ts index 1ba8f15..21f235d 100644 --- a/backend/src/modules/types/credentials.types.ts +++ b/backend/src/modules/types/credentials.types.ts @@ -1,11 +1,35 @@ import z from "zod"; export const RegisterSchema = z.object({ - email: z.string().email("Email inválido"), - password: z.string().min(8, "Senha deve ter no mínimo 8 caracteres"), - name: z.string().min(1, "Nome é obrigatório").optional(), - phone: z.string().optional(), - cpf: z.string().optional(), + email: z + .string() + .email("Email inválido") + .max(254, "Email deve ter no máximo 254 caracteres"), + password: z + .string() + .min(8, "Senha deve ter no mínimo 8 caracteres") + .max(128, "Senha deve ter no máximo 128 caracteres"), + name: z + .string() + .min(1, "Nome é obrigatório") + .max(100, "Nome deve ter no máximo 100 caracteres") + .optional(), + phone: z + .string() + .max(16, "Telefone deve ter no máximo 15 dígitos") + .refine( + (value) => value.replace(/\D/g, "").length <= 15, + "Telefone deve ter no máximo 15 dígitos", + ) + .optional(), + cpf: z + .string() + .max(14, "CPF deve ter no máximo 11 dígitos") + .refine( + (value) => value.replace(/\D/g, "").length <= 11, + "CPF deve ter no máximo 11 dígitos", + ) + .optional(), technologies: z.array(z.string()).optional(), level: z.string().optional(), }); diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 988f0d2..49d7b23 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -101,17 +101,19 @@ describe("CredentialsService", () => { }; }); mocks.insertReturning.mockResolvedValue([mockUser]); - mocks.transaction.mockImplementation(async (callback) => callback({ - insert: vi.fn(() => ({ - values: mocks.insertValues.mockImplementation(() => ({ - returning: mocks.insertReturning, + mocks.transaction.mockImplementation(async (callback) => + callback({ + insert: vi.fn(() => ({ + values: mocks.insertValues.mockImplementation(() => ({ + returning: mocks.insertReturning, + })), })), - })), - query: { - credentials: { findFirst: mocks.credentialsFindFirst }, - users: { findFirst: mocks.usersFindFirst }, - }, - })); + query: { + credentials: { findFirst: mocks.credentialsFindFirst }, + users: { findFirst: mocks.usersFindFirst }, + }, + }), + ); // Define retornos seguros e limpos para evitar falhas colaterais mocks.credentialsFindFirst.mockResolvedValue(null); @@ -178,6 +180,18 @@ describe("CredentialsService", () => { ).rejects.toThrow(); }); + it.each([ + ["nome", { name: "a".repeat(101) }], + ["email", { email: `${"a".repeat(250)}@x.com` }], + ["telefone", { phone: "1".repeat(16) }], + ["senha", { password: "a".repeat(129) }], + ["CPF", { cpf: "1".repeat(12) }], + ])("rejeita %s acima do limite permitido", async (_field, override) => { + await expect( + service.register({ ...registerInput, ...override }), + ).rejects.toThrow(); + }); + it("session contém userId e role", async () => { const { session } = await service.register(registerInput); diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index b03da44..cfc836c 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -30,6 +30,14 @@ const LEVEL_OPTIONS = [ { value: "senior", label: "Sênior" }, ]; +const REGISTER_LIMITS = { + name: 100, + email: 254, + phoneDigits: 15, + password: 128, + cpf: 14, +} as const; + function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -163,8 +171,8 @@ export default function RegisterSide() { if (!password) { setPasswordError("O campo de senha é obrigatório."); isValid = false; - } else if (password.length < 6) { - setPasswordError("A senha precisa conter pelo menos 6 caracteres."); + } else if (password.length < 8) { + setPasswordError("A senha precisa conter pelo menos 8 caracteres."); isValid = false; } @@ -196,7 +204,9 @@ export default function RegisterSide() { window.location.href = "/login?registered=true"; } catch (error: unknown) { console.error("Erro no cadastro:", error); - setApiError(getErrorMessage(error, "Erro ao cadastrar. Tente novamente.")); + setApiError( + getErrorMessage(error, "Erro ao cadastrar. Tente novamente."), + ); } finally { setIsLoading(false); } @@ -260,6 +270,7 @@ export default function RegisterSide() { type="text" value={nome} onChange={(e) => setNome(e.target.value)} + maxLength={REGISTER_LIMITS.name} placeholder="benevanio" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${nomeError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -282,6 +293,7 @@ export default function RegisterSide() { type="email" value={email} onChange={(e) => setEmail(e.target.value)} + maxLength={REGISTER_LIMITS.email} placeholder="benevanio@dev.com.br" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${emailError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -303,7 +315,15 @@ export default function RegisterSide() { international defaultCountry="BR" value={telefone} - onChange={setTelefone} + onChange={(value) => { + if ( + !value || + value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits + ) { + setTelefone(value); + } + }} + numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }} disabled={isLoading} className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom" placeholder="(34) 23456-7890" @@ -328,6 +348,7 @@ export default function RegisterSide() { type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} + maxLength={REGISTER_LIMITS.password} placeholder="Ex: ••••••••••••" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${passwordError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -366,6 +387,7 @@ export default function RegisterSide() { type="text" value={cpf} onChange={(e) => setCpf(formatCpf(e.target.value))} + maxLength={REGISTER_LIMITS.cpf} placeholder="091.000.000-00" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${cpfError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} diff --git a/frontend/tests/unit/components/login/RegisterSide.test.tsx b/frontend/tests/unit/components/login/RegisterSide.test.tsx index 8f0c026..0486826 100644 --- a/frontend/tests/unit/components/login/RegisterSide.test.tsx +++ b/frontend/tests/unit/components/login/RegisterSide.test.tsx @@ -19,7 +19,7 @@ function fillRequiredRegisterFields() { target: { value: "+5534999999999" }, }); fireEvent.change(screen.getByLabelText(/senha/i), { - target: { value: "123456" }, + target: { value: "12345678" }, }); fireEvent.change(screen.getByLabelText(/nível de experiência/i), { target: { value: "pleno" }, @@ -40,18 +40,27 @@ vi.mock("@unpic/react", () => ({ vi.mock("framer-motion", () => ({ motion: { div: ({ children, ...props }: any) =>
{children}
, - button: ({ children, ...props }: any) => , + button: ({ children, ...props }: any) => ( + + ), }, })); vi.mock("react-phone-number-input", () => ({ - default: ({ value, onChange, placeholder, disabled }: any) => ( + default: ({ + value, + onChange, + placeholder, + disabled, + numberInputProps, + }: any) => ( onChange(e.target.value)} placeholder={placeholder} disabled={disabled} + {...numberInputProps} /> ), })); @@ -94,24 +103,71 @@ describe("RegisterSide", () => { expect(screen.getByLabelText(/senha/i)).toHaveAttribute("type", "text"); }); + it("limita os campos de cadastro conforme as regras da API", () => { + render(); + + expect(screen.getByLabelText(/nome/i)).toHaveAttribute("maxlength", "100"); + expect(screen.getByLabelText(/email/i)).toHaveAttribute("maxlength", "254"); + expect(screen.getByPlaceholderText(/\(34\)/i)).toHaveAttribute( + "maxlength", + "16", + ); + expect(screen.getByLabelText(/senha/i)).toHaveAttribute("maxlength", "128"); + expect(screen.getByLabelText(/cpf/i)).toHaveAttribute("maxlength", "14"); + }); + + it("exige senha com pelo menos oito caracteres", async () => { + render(); + fillRequiredRegisterFields(); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "1234567" }, + }); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + expect( + await screen.findByText(/pelo menos 8 caracteres/i), + ).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + it("mostra erros obrigatórios ao submeter vazio", async () => { render(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); - expect(await screen.findByText(/campo de nome é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de e-mail é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de telefone é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de senha é obrigatório/i)).toBeInTheDocument(); + expect( + await screen.findByText(/campo de nome é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de e-mail é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de telefone é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de senha é obrigatório/i), + ).toBeInTheDocument(); expect(await screen.findByText(/selecione seu nível/i)).toBeInTheDocument(); }); it("valida CPF inválido quando preenchido", async () => { render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Usuário" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "teste@email.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); - fireEvent.change(screen.getByLabelText(/nível de experiência/i), { target: { value: "pleno" } }); - fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "123" } }); + fireEvent.change(screen.getByLabelText(/nome/i), { + target: { value: "Usuário" }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "teste@email.com" }, + }); + fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { + target: { value: "+5534999999999" }, + }); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "12345678" }, + }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { + target: { value: "pleno" }, + }); + fireEvent.change(screen.getByLabelText(/cpf/i), { + target: { value: "123" }, + }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); expect(await screen.findByText(/cpf inválido/i)).toBeInTheDocument(); }); @@ -124,7 +180,7 @@ describe("RegisterSide", () => { await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", - password: "123456", + password: "12345678", name: "Bene", phone: "+5534999999999", cpf: undefined, @@ -138,12 +194,14 @@ describe("RegisterSide", () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); fillRequiredRegisterFields(); - fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "12345678901" } }); + fireEvent.change(screen.getByLabelText(/cpf/i), { + target: { value: "12345678901" }, + }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", - password: "123456", + password: "12345678", name: "Bene", phone: "+5534999999999", cpf: "123.456.789-01", @@ -165,7 +223,9 @@ describe("RegisterSide", () => { render(); fillRequiredRegisterFields(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); - expect(await screen.findByRole("button", { name: /cadastrando\.\.\./i })).toBeDisabled(); + expect( + await screen.findByRole("button", { name: /cadastrando\.\.\./i }), + ).toBeDisabled(); }); it("formata CPF corretamente", () => { @@ -188,7 +248,7 @@ describe("RegisterSide", () => { fireEvent.change(nomeInput, { target: { value: "Bene" } }); fireEvent.change(emailInput, { target: { value: "bene@teste.com" } }); fireEvent.change(telefoneInput, { target: { value: "+5534999999999" } }); - fireEvent.change(passwordInput, { target: { value: "123456" } }); + fireEvent.change(passwordInput, { target: { value: "12345678" } }); fireEvent.change(levelInput, { target: { value: "pleno" } }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { @@ -202,30 +262,36 @@ describe("RegisterSide", () => { it("redireciona para GitHub OAuth ao clicar no botao GitHub", async () => { mockGetGithubAuthUrl.mockResolvedValueOnce( - "https://github.com/login/oauth/authorize?state=abc" + "https://github.com/login/oauth/authorize?state=abc", ); render(); const buttons = screen.getAllByRole("button"); - const githubButton = buttons.find(btn => btn.querySelector('svg.fill-gray-900')); + const githubButton = buttons.find((btn) => + btn.querySelector("svg.fill-gray-900"), + ); fireEvent.click(githubButton!); await waitFor(() => { expect(mockGetGithubAuthUrl).toHaveBeenCalled(); expect(window.location.href).toBe( - "https://github.com/login/oauth/authorize?state=abc" + "https://github.com/login/oauth/authorize?state=abc", ); }); }); it("exibe erro quando GitHub OAuth falha", async () => { - mockGetGithubAuthUrl.mockRejectedValueOnce(new Error("Github indisponível")); + mockGetGithubAuthUrl.mockRejectedValueOnce( + new Error("Github indisponível"), + ); render(); const buttons = screen.getAllByRole("button"); - const githubButton = buttons.find(btn => btn.querySelector('svg.fill-gray-900')); + const githubButton = buttons.find((btn) => + btn.querySelector("svg.fill-gray-900"), + ); fireEvent.click(githubButton!); expect(await screen.findByText(/Github indisponível/i)).toBeInTheDocument(); @@ -233,7 +299,7 @@ describe("RegisterSide", () => { it("redireciona para LinkedIn OAuth ao clicar no botao LinkedIn", async () => { mockGetLinkedinAuthUrl.mockResolvedValueOnce( - "https://www.linkedin.com/oauth/v2/authorization?state=abc" + "https://www.linkedin.com/oauth/v2/authorization?state=abc", ); render(); @@ -244,7 +310,7 @@ describe("RegisterSide", () => { await waitFor(() => { expect(mockGetLinkedinAuthUrl).toHaveBeenCalled(); expect(window.location.href).toBe( - "https://www.linkedin.com/oauth/v2/authorization?state=abc" + "https://www.linkedin.com/oauth/v2/authorization?state=abc", ); }); }); From 64ebdca008d7528caef3ed7ab00af420e7449c85 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Wed, 29 Jul 2026 00:00:58 -0300 Subject: [PATCH 3/7] fix(filtros): limitar entradas e truncar textos longos --- .../components/JobsFiltersCard.tsx | 8 ++-- .../presentation/components/KeywordsModal.tsx | 36 ++++++++++++++---- .../components/jobs/JobFilter.tsx | 1 + .../unit/components/JobsFiltersCard.test.tsx | 13 +++++++ .../unit/components/KeywordsModal.test.tsx | 36 +++++++++++++----- .../tests/unit/new_dashboard/jobs.test.tsx | 38 ++++++++----------- 6 files changed, 91 insertions(+), 41 deletions(-) diff --git a/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx b/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx index bbaa0c3..6b781e3 100644 --- a/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx +++ b/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx @@ -115,6 +115,7 @@ export function JobsFiltersCard({ setSearchTerm(event.target.value)} + maxLength={100} className="h-14 w-full rounded-2xl border border-slate-300 bg-white pl-11 pr-14 text-base text-slate-900 placeholder:text-slate-500 focus-visible:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#091224] dark:text-slate-100 dark:placeholder:text-slate-400" placeholder="Buscar por título, empresa, local ou link" /> @@ -183,11 +184,12 @@ export function JobsFiltersCard({ selectedFilters.map((filter) => ( - {filter} + {filter}