import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { db } from "../src/db/index.js"; import { sessions } from "../src/db/schema.js"; import { createTestApp, cleanDb, requestSignupOtpCode, signupUser, loginUser } from "./helpers.js"; let app: FastifyInstance; beforeAll(async () => { app = await createTestApp(); }); afterAll(async () => { await app.close(); }); beforeEach(async () => { await cleanDb(); }); describe("POST /login/verify", () => { it("creates session for existing user", async () => { await signupUser(app, "user@example.com", "Org"); const res = await loginUser(app, "user@example.com"); const body = res.json(); expect(res.statusCode).toBe(200); expect(body.accessToken).toBeDefined(); expect(body.refreshToken).toBeDefined(); expect(body.user.email).toBe("user@example.com"); expect(body.accounts).toHaveLength(1); }); it("returns 401 for unknown email", async () => { // Request OTP via signup flow (user doesn't exist yet) const code = await requestSignupOtpCode(app, "unknown@example.com"); const res = await app.inject({ method: "POST", url: "/login/verify", payload: { email: "unknown@example.com", code }, }); expect(res.statusCode).toBe(401); expect(res.json().error).toMatch(/sign up/i); }); it("returns 400 with invalid OTP", async () => { const res = await app.inject({ method: "POST", url: "/login/verify", payload: { email: "user@example.com", code: "000000" }, }); expect(res.statusCode).toBe(400); }); it("returns 400 if email or code is missing", async () => { const res = await app.inject({ method: "POST", url: "/login/verify", payload: { email: "user@example.com" }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/required/i); }); }); describe("POST /refresh", () => { it("rotates tokens and returns user + accounts", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { refreshToken } = signup.json(); const res = await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken }, }); const body = res.json(); expect(res.statusCode).toBe(200); expect(body.accessToken).toBeDefined(); expect(body.refreshToken).not.toBe(refreshToken); expect(body.user.email).toBe("user@example.com"); expect(body.accounts).toHaveLength(1); }); it("returns 400 if refreshToken is missing", async () => { const res = await app.inject({ method: "POST", url: "/refresh", payload: {}, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/required/i); }); it("returns 401 for invalid refresh token", async () => { const res = await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken: "invalid-token" }, }); expect(res.statusCode).toBe(401); }); it("returns 401 for expired refresh token", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { refreshToken } = signup.json(); // Expire the session manually await db .update(sessions) .set({ expiresAt: new Date(Date.now() - 1000) }) .where(eq(sessions.refreshToken, refreshToken)); const res = await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken }, }); expect(res.statusCode).toBe(401); expect(res.json().error).toMatch(/expired/i); }); it("detects token reuse and revokes all sessions", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { refreshToken } = signup.json(); // First refresh succeeds await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken }, }); // Reuse the old token — should be detected as theft const res = await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken }, }); expect(res.statusCode).toBe(401); expect(res.json().error).toMatch(/reuse/i); }); }); describe("POST /logout", () => { it("revokes all sessions", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { accessToken, refreshToken } = signup.json(); const res = await app.inject({ method: "POST", url: "/logout", headers: { authorization: `Bearer ${accessToken}` }, }); expect(res.statusCode).toBe(204); // Refresh token should no longer work const refresh = await app.inject({ method: "POST", url: "/refresh", payload: { refreshToken }, }); expect(refresh.statusCode).toBe(401); }); it("returns 401 without auth header", async () => { const res = await app.inject({ method: "POST", url: "/logout", }); expect(res.statusCode).toBe(401); }); });