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:
2026-09-04 08:02:33 -03:00
parent 012c00fa4d
commit 4d23af3b23
16 changed files with 525 additions and 8 deletions

View 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);
});
});