aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes/auth.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/routes/auth.test.ts')
-rw-r--r--src/server/routes/auth.test.ts428
1 files changed, 428 insertions, 0 deletions
diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts
new file mode 100644
index 0000000..95fd6e9
--- /dev/null
+++ b/src/server/routes/auth.test.ts
@@ -0,0 +1,428 @@
+import { Hono } from "hono";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { errorHandler } from "../middleware/index.js";
+import type {
+ RefreshTokenRepository,
+ UserPublic,
+ UserRepository,
+} from "../repositories/index.js";
+import { createAuthRouter } from "./auth.js";
+
+vi.mock("argon2", () => ({
+ hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)),
+ verify: vi.fn((hash: string, password: string) =>
+ Promise.resolve(hash === `hashed_${password}`),
+ ),
+}));
+
+function createMockUserRepo(): UserRepository {
+ return {
+ findByUsername: vi.fn(),
+ existsByUsername: vi.fn(),
+ create: vi.fn(),
+ findById: vi.fn(),
+ };
+}
+
+function createMockRefreshTokenRepo(): RefreshTokenRepository {
+ return {
+ findValidToken: vi.fn(),
+ create: vi.fn(),
+ deleteById: vi.fn(),
+ };
+}
+
+interface RegisterResponse {
+ user?: {
+ id: string;
+ username: string;
+ createdAt: string;
+ };
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+interface LoginResponse {
+ accessToken?: string;
+ refreshToken?: string;
+ user?: {
+ id: string;
+ username: string;
+ };
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("POST /register", () => {
+ let app: Hono;
+ let mockUserRepo: ReturnType<typeof createMockUserRepo>;
+ let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUserRepo = createMockUserRepo();
+ mockRefreshTokenRepo = createMockRefreshTokenRepo();
+ const auth = createAuthRouter({
+ userRepo: mockUserRepo,
+ refreshTokenRepo: mockRefreshTokenRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/auth", auth);
+ });
+
+ it("creates a new user with valid credentials", async () => {
+ vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(false);
+ vi.mocked(mockUserRepo.create).mockResolvedValue({
+ id: "test-uuid-123",
+ username: "testuser",
+ createdAt: new Date("2024-01-01T00:00:00Z"),
+ } as UserPublic);
+
+ const res = await app.request("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "securepassword12345",
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as RegisterResponse;
+ expect(body.user).toEqual({
+ id: "test-uuid-123",
+ username: "testuser",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ });
+ expect(mockUserRepo.existsByUsername).toHaveBeenCalledWith("testuser");
+ expect(mockUserRepo.create).toHaveBeenCalledWith({
+ username: "testuser",
+ passwordHash: "hashed_securepassword12345",
+ });
+ });
+
+ it("returns 422 for invalid username", async () => {
+ const res = await app.request("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "",
+ password: "securepassword12345",
+ }),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as RegisterResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("returns 422 for password too short", async () => {
+ const res = await app.request("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "tooshort123456",
+ }),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as RegisterResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("returns 409 for existing username", async () => {
+ vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true);
+
+ const res = await app.request("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "existinguser",
+ password: "securepassword12345",
+ }),
+ });
+
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as RegisterResponse;
+ expect(body.error?.code).toBe("USERNAME_EXISTS");
+ });
+});
+
+describe("POST /login", () => {
+ let app: Hono;
+ let mockUserRepo: ReturnType<typeof createMockUserRepo>;
+ let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUserRepo = createMockUserRepo();
+ mockRefreshTokenRepo = createMockRefreshTokenRepo();
+ const auth = createAuthRouter({
+ userRepo: mockUserRepo,
+ refreshTokenRepo: mockRefreshTokenRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/auth", auth);
+ });
+
+ it("returns access token for valid credentials", async () => {
+ vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({
+ id: "user-uuid-123",
+ username: "testuser",
+ passwordHash: "hashed_correctpassword",
+ });
+ vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "correctpassword",
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as LoginResponse;
+ expect(body.accessToken).toBeDefined();
+ expect(typeof body.accessToken).toBe("string");
+ expect(body.refreshToken).toBeDefined();
+ expect(typeof body.refreshToken).toBe("string");
+ expect(body.user).toEqual({
+ id: "user-uuid-123",
+ username: "testuser",
+ });
+ expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({
+ userId: "user-uuid-123",
+ tokenHash: expect.any(String),
+ expiresAt: expect.any(Date),
+ });
+ });
+
+ it("returns 401 for non-existent user", async () => {
+ vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "nonexistent",
+ password: "anypassword",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as LoginResponse;
+ expect(body.error?.code).toBe("INVALID_CREDENTIALS");
+ });
+
+ it("returns 401 for incorrect password", async () => {
+ vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({
+ id: "user-uuid-123",
+ username: "testuser",
+ passwordHash: "hashed_correctpassword",
+ });
+
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "wrongpassword",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as LoginResponse;
+ expect(body.error?.code).toBe("INVALID_CREDENTIALS");
+ });
+
+ it("returns 422 for missing username", async () => {
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "",
+ password: "somepassword",
+ }),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as LoginResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("returns 422 for missing password", async () => {
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "",
+ }),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as LoginResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+});
+
+interface RefreshResponse {
+ accessToken?: string;
+ refreshToken?: string;
+ user?: {
+ id: string;
+ username: string;
+ };
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("POST /refresh", () => {
+ let app: Hono;
+ let mockUserRepo: ReturnType<typeof createMockUserRepo>;
+ let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUserRepo = createMockUserRepo();
+ mockRefreshTokenRepo = createMockRefreshTokenRepo();
+ const auth = createAuthRouter({
+ userRepo: mockUserRepo,
+ refreshTokenRepo: mockRefreshTokenRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/auth", auth);
+ });
+
+ it("returns new tokens for valid refresh token", async () => {
+ vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({
+ id: "token-id-123",
+ userId: "user-uuid-123",
+ expiresAt: new Date(Date.now() + 86400000),
+ });
+ vi.mocked(mockUserRepo.findById).mockResolvedValue({
+ id: "user-uuid-123",
+ username: "testuser",
+ });
+ vi.mocked(mockRefreshTokenRepo.deleteById).mockResolvedValue(undefined);
+ vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ refreshToken: "valid-refresh-token-hex",
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.accessToken).toBeDefined();
+ expect(typeof body.accessToken).toBe("string");
+ expect(body.refreshToken).toBeDefined();
+ expect(typeof body.refreshToken).toBe("string");
+ expect(body.user).toEqual({
+ id: "user-uuid-123",
+ username: "testuser",
+ });
+ expect(mockRefreshTokenRepo.deleteById).toHaveBeenCalledWith(
+ "token-id-123",
+ );
+ expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({
+ userId: "user-uuid-123",
+ tokenHash: expect.any(String),
+ expiresAt: expect.any(Date),
+ });
+ });
+
+ it("returns 401 for invalid refresh token", async () => {
+ vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ refreshToken: "invalid-refresh-token",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN");
+ });
+
+ it("returns 401 for expired refresh token", async () => {
+ vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ refreshToken: "expired-refresh-token",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN");
+ });
+
+ it("returns 401 when user not found", async () => {
+ vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({
+ id: "token-id-123",
+ userId: "deleted-user-id",
+ expiresAt: new Date(Date.now() + 86400000),
+ });
+ vi.mocked(mockUserRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ refreshToken: "valid-refresh-token",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.error?.code).toBe("USER_NOT_FOUND");
+ });
+
+ it("returns 422 for missing refresh token", async () => {
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("returns 422 for empty refresh token", async () => {
+ const res = await app.request("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ refreshToken: "",
+ }),
+ });
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as RefreshResponse;
+ expect(body.error?.code).toBe("VALIDATION_ERROR");
+ });
+});