feat(auth): e-mail transacional real + recuperação de senha
Liga o adapter SMTP existente (packages/integrations, já era fail-soft) a um consumidor de verdade e adiciona o fluxo completo de recuperação de senha, faltante desde a Fase 1. - UsersService.invite() agora envia e-mail com a senha temporária (mantém o retorno na resposta também — dupla entrega, eden.md §4.8). - POST /v1/auth/forgot-password + POST /v1/auth/reset-password: token de 256 bits, só o hash SHA-256 persistido (users.reset_token_hash/ reset_token_expires_at — colunas que já existiam desde a baseline da Fase 1, nunca usadas até agora), validade de 1h, uso único, revoga todas as sessões ativas do usuário ao concluir. Mesma disciplina anti-enumeração do login: resposta idêntica para e-mail cadastrado ou não. - Frontend: link "Esqueci minha senha" no LoginPage, novas ForgotPasswordPage/ResetPasswordPage. Teste de integração novo (password-reset.spec.ts, 3 testes) usa vi.mock para interceptar sendMail e recuperar o token real do corpo do e-mail — única forma de testar o caminho feliz de ponta a ponta, já que o token nunca é devolvido na resposta da API. Suíte completa: 33/33 (30→33). pnpm turbo run typecheck build (28/28). Testado ao vivo através do proxy same-origin: anti-enumeração confirmada com curl real, token gravado no Postgres, token bogus rejeitado (400). Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
121
apps/api/test/password-reset.spec.ts
Normal file
121
apps/api/test/password-reset.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agent,
|
||||
bearer,
|
||||
closeTestApp,
|
||||
createTestApp,
|
||||
loginSuperAdmin,
|
||||
qaEmail,
|
||||
type AuthedTokens,
|
||||
} from "./setup.js";
|
||||
|
||||
/**
|
||||
* `sendMail` is mocked (not stubbed-out — the real call path through
|
||||
* auth.service.ts still runs) so this test can recover the plaintext
|
||||
* reset token, which by design is NEVER returned in any API response —
|
||||
* only emailed. This mirrors how a real user would get it: from the
|
||||
* email body. `packages/integrations`' own mailer is fail-soft when SMTP
|
||||
* isn't configured (confirmed by the other specs' harmless console
|
||||
* logs), but that's irrelevant here since we intercept before it runs.
|
||||
*/
|
||||
const sendMailMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@eden/integrations", () => ({
|
||||
sendMail: (...args: unknown[]) => sendMailMock(...args),
|
||||
}));
|
||||
|
||||
function extractTokenFromLastEmail(): string {
|
||||
const lastCall = sendMailMock.mock.calls.at(-1)?.[0] as { html: string } | undefined;
|
||||
const match = lastCall?.html.match(/token=([a-f0-9]{64})/);
|
||||
if (!match?.[1]) throw new Error("Reset token not found in the last mocked email");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protects the password-reset invariants added in auth.service.ts
|
||||
* (forgotPassword/resetPassword) — anti-enumeration on request, token
|
||||
* single-use, expiry, and session revocation on completion.
|
||||
*/
|
||||
describe("auth — password reset", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let fixtureEmail: string;
|
||||
let fixtureUserId: string;
|
||||
let fixturePassword: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
|
||||
fixtureEmail = qaEmail("pwreset");
|
||||
const invite = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email: fixtureEmail, roleKey: "user", applications: ["core"] })
|
||||
.expect(201);
|
||||
fixtureUserId = invite.body.user.id;
|
||||
fixturePassword = invite.body.tempPassword;
|
||||
sendMailMock.mockClear(); // discard the invite email capture, not relevant here
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/users/${fixtureUserId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("forgot-password returns the same generic response for a registered and an unregistered email", async () => {
|
||||
const known = await agent(app).post("/api/v1/auth/forgot-password").send({ email: fixtureEmail }).expect(200);
|
||||
const unknown = await agent(app)
|
||||
.post("/api/v1/auth/forgot-password")
|
||||
.send({ email: qaEmail("nobody") })
|
||||
.expect(200);
|
||||
expect(known.body.message).toBe(unknown.body.message);
|
||||
|
||||
// Only the registered email actually triggers a send.
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMailMock.mock.calls[0]?.[0].to).toBe(fixtureEmail);
|
||||
});
|
||||
|
||||
it("a bogus token is rejected", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/reset-password")
|
||||
.send({ token: "0".repeat(64), newPassword: "brandNewPassword123" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("completing the reset changes the password, revokes existing sessions, and the token is single-use", async () => {
|
||||
// A real active session, established BEFORE the reset — must not survive it.
|
||||
const sessionBeforeReset = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
const oldRefreshToken = sessionBeforeReset.body.refreshToken as string;
|
||||
|
||||
sendMailMock.mockClear();
|
||||
await agent(app).post("/api/v1/auth/forgot-password").send({ email: fixtureEmail }).expect(200);
|
||||
const token = extractTokenFromLastEmail();
|
||||
|
||||
const newPassword = "brandNewPassword123";
|
||||
await agent(app).post("/api/v1/auth/reset-password").send({ token, newPassword }).expect(204);
|
||||
|
||||
// Old session is dead — reset revokes every existing session.
|
||||
await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: oldRefreshToken }).expect(401);
|
||||
|
||||
// Old password no longer works; the new one does.
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(401);
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: newPassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
|
||||
// The same token cannot be replayed.
|
||||
await agent(app).post("/api/v1/auth/reset-password").send({ token, newPassword: "yetAnotherPassword456" }).expect(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user