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>
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|