eyrun-api/tests/sessions.test.ts
2026-02-08 13:39:33 +01:00

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