import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { verifyAccessToken, hashToken } from "../services/auth"; import jwt from "jsonwebtoken"; import { config } from "../config/env"; import prisma from "../config/database"; // Prefix for fixtures so cleanup never touches real data. const N = "auth_svc_test_"; let testUserId: number; let testUsername: string; let testRoleName: string | null; beforeAll(async () => { // Clean up any leftover fixtures from a previous failed run. await prisma.users .deleteMany({ where: { username: { startsWith: N } } }) .catch(() => {}); // Use a real (active, unlocked) user from the seeded DB so loadAuthData // returns a full AuthData. We don't need a specific role — any active user // works — but we record its role so we can assert roleName precisely. const role = await prisma.roles.findFirst(); if (!role) throw new Error("Test setup: no roles found — seed the database"); const user = await prisma.users.create({ data: { username: `${N}user`, email: `${N}user@test.local`, // DUMMY_HASH used elsewhere in the suite; we never verify a password // here, only that the token's `sub` resolves to this user. password_hash: "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", first_name: "Auth", last_name: "Service", is_active: true, role_id: role.id, }, include: { roles: true }, }); testUserId = user.id; testUsername = user.username; testRoleName = user.roles?.name ?? null; }); afterAll(async () => { await prisma.users .deleteMany({ where: { username: { startsWith: N } } }) .catch(() => {}); }); describe("auth service", () => { describe("verifyAccessToken", () => { it("returns AuthData for a valid token signed for a real user", async () => { const token = jwt.sign( { sub: testUserId, username: testUsername, role: testRoleName }, config.jwt.secret, { expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" }, ); const result = await verifyAccessToken(token); expect(result).not.toBeNull(); expect(result!.userId).toBe(testUserId); expect(result!.username).toBe(testUsername); expect(result!.roleName).toBe(testRoleName); // Permissions resolve to an array (admins get all, others get role perms). expect(Array.isArray(result!.permissions)).toBe(true); }); it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => { const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); const result = await verifyAccessToken("invalid-token"); expect(result).toBeNull(); // Invalid/expired access tokens are an expected condition (the client // refreshes on the resulting 401), so verifyAccessToken deliberately // does NOT log them as errors — only genuinely unexpected failures. expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); it("returns null WITHOUT logging for an expired JWT", async () => { const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); const expiredToken = jwt.sign( { sub: 1, username: "test", role: "user" }, config.jwt.secret, { expiresIn: -1 }, ); const result = await verifyAccessToken(expiredToken); expect(result).toBeNull(); expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); }); describe("hashToken", () => { it("matches the known SHA-256 vector for a fixed input", () => { // SHA-256("hello") — pins the algorithm so a silent change (e.g. to a // different but still 64-hex-deterministic hash) is caught. expect(hashToken("hello")).toBe( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", ); }); it("produces deterministic SHA-256 hex output", () => { const t1 = hashToken("hello"); const t2 = hashToken("hello"); expect(t1).toBe(t2); expect(t1).toMatch(/^[a-f0-9]{64}$/); }); it("produces different hashes for different inputs", () => { const t1 = hashToken("a"); const t2 = hashToken("b"); expect(t1).not.toBe(t2); }); }); });