From 742c3e55b08b37d0eb031f72a6d952bc7b7164b3 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 3 Dec 2025 05:34:44 +0900 Subject: feat(auth): add login endpoint with JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement POST /api/auth/login endpoint that validates credentials and returns a JWT access token on successful authentication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkgs/server/src/routes/auth.test.ts | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) (limited to 'pkgs/server/src/routes/auth.test.ts') diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts index 2d60636..1dfba46 100644 --- a/pkgs/server/src/routes/auth.test.ts +++ b/pkgs/server/src/routes/auth.test.ts @@ -48,6 +48,9 @@ vi.mock("../db", () => { vi.mock("argon2", () => ({ hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), + verify: vi.fn((hash: string, password: string) => + Promise.resolve(hash === `hashed_${password}`), + ), })); interface RegisterResponse { @@ -62,6 +65,18 @@ interface RegisterResponse { }; } +interface LoginResponse { + accessToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + describe("POST /register", () => { let app: Hono; @@ -146,3 +161,133 @@ describe("POST /register", () => { expect(body.error?.code).toBe("USERNAME_EXISTS"); }); }); + +describe("POST /login", () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns access token for valid credentials", async () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }, + ]), + }), + }), + } as unknown as ReturnType); + + 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.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + }); + + it("returns 401 for non-existent user", async () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as unknown as ReturnType); + + 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 () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }, + ]), + }), + }), + } as unknown as ReturnType); + + 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"); + }); +}); -- cgit v1.2.3-70-g09d2