Files
SkyMoney/api/tests/forgot-password.security.test.ts
Ricearoni1245 15e0c0a88a
Some checks failed
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Failing after 18s
Security Tests / security-db (push) Failing after 22s
feat: implement forgot password, added security updates
2026-03-01 21:47:15 -06:00

177 lines
6.1 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { createHash } from "node:crypto";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
beforeAll(async () => {
app = await buildApp({
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20,
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20,
});
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("Forgot password security", () => {
it("returns generic success and only issues reset token for verified users", async () => {
const verifiedEmail = `fp-verified-${Date.now()}@test.dev`;
const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`;
const passwordHash = await argon2.hash("SupersAFE123!");
const [verifiedUser, unverifiedUser] = await Promise.all([
prisma.user.create({
data: { email: verifiedEmail, passwordHash, emailVerified: true },
}),
prisma.user.create({
data: { email: unverifiedEmail, passwordHash, emailVerified: false },
}),
]);
const unknownRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: `missing-${Date.now()}@test.dev` });
const verifiedRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: verifiedEmail });
const unverifiedRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: unverifiedEmail });
expect(unknownRes.status).toBe(200);
expect(verifiedRes.status).toBe(200);
expect(unverifiedRes.status).toBe(200);
expect(unknownRes.body).toEqual({
ok: true,
message: "If an account exists, reset instructions were sent.",
});
expect(verifiedRes.body).toEqual(unknownRes.body);
expect(unverifiedRes.body).toEqual(unknownRes.body);
const [verifiedTokens, unverifiedTokens] = await Promise.all([
prisma.emailToken.count({
where: { userId: verifiedUser.id, type: "password_reset", usedAt: null },
}),
prisma.emailToken.count({
where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null },
}),
]);
expect(verifiedTokens).toBe(1);
expect(unverifiedTokens).toBe(0);
await prisma.user.deleteMany({
where: { email: { in: [verifiedEmail, unverifiedEmail] } },
});
});
it("consumes reset token once, updates password, and invalidates prior session", async () => {
const email = `fp-confirm-${Date.now()}@test.dev`;
const oldPassword = "SupersAFE123!";
const newPassword = "EvenStrong3rPass!";
const rawToken = "reset-token-value-0123456789abcdef";
const user = await prisma.user.create({
data: {
email,
emailVerified: true,
passwordHash: await argon2.hash(oldPassword),
},
});
const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword });
expect(login.status).toBe(200);
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session="));
expect(sessionCookie).toBeTruthy();
await prisma.emailToken.create({
data: {
userId: user.id,
type: "password_reset",
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + 30 * 60_000),
},
});
const confirm = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: user.id, token: rawToken, newPassword });
expect(confirm.status).toBe(200);
expect(confirm.body.ok).toBe(true);
const tokenRecord = await prisma.emailToken.findFirst({
where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) },
});
expect(tokenRecord?.usedAt).toBeTruthy();
const secondUse = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" });
expect(secondUse.status).toBe(400);
expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
const oldSessionAccess = await request(app.server)
.get("/dashboard")
.set("Cookie", [sessionCookie as string]);
expect(oldSessionAccess.status).toBe(401);
const oldPasswordLogin = await request(app.server)
.post("/auth/login")
.send({ email, password: oldPassword });
expect(oldPasswordLogin.status).toBe(401);
const newPasswordLogin = await request(app.server)
.post("/auth/login")
.send({ email, password: newPassword });
expect(newPasswordLogin.status).toBe(200);
await prisma.user.deleteMany({ where: { id: user.id } });
});
it("rejects uid/token mismatches with generic error", async () => {
const emailA = `fp-a-${Date.now()}@test.dev`;
const emailB = `fp-b-${Date.now()}@test.dev`;
const rawToken = "mismatch-token-value-abcdef0123456789";
const [userA, userB] = await Promise.all([
prisma.user.create({
data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
}),
prisma.user.create({
data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
}),
]);
await prisma.emailToken.create({
data: {
userId: userA.id,
type: "password_reset",
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + 30 * 60_000),
},
});
const mismatch = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" });
expect(mismatch.status).toBe(400);
expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } });
});
});