186 lines
5.0 KiB
TypeScript
186 lines
5.0 KiB
TypeScript
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);
|
|
});
|
|
});
|