diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-03 05:28:29 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:25 +0900 |
| commit | 950217ed3ca93a0aa0e964c2a8474ffc13c71912 (patch) | |
| tree | f5a8575a74aa8203a8fd49846ab859670009ffd7 /pkgs/server/src | |
| parent | 7e75e0fa8f26a7890c210c67e4474778811b15bc (diff) | |
| download | kioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.tar.gz kioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.tar.zst kioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.zip | |
feat(auth): add user registration endpoint
Implement POST /api/auth/register endpoint with:
- Argon2 password hashing
- Zod validation for username (1-255 chars) and password (8-255 chars)
- Duplicate username check (returns 409 Conflict)
- Returns created user with id, username, and createdAt
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server/src')
| -rw-r--r-- | pkgs/server/src/index.ts | 3 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.test.ts | 148 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 50 | ||||
| -rw-r--r-- | pkgs/server/src/routes/index.ts | 1 |
4 files changed, 202 insertions, 0 deletions
diff --git a/pkgs/server/src/index.ts b/pkgs/server/src/index.ts index a00c9fd..a0ae0a4 100644 --- a/pkgs/server/src/index.ts +++ b/pkgs/server/src/index.ts @@ -2,6 +2,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; import { errorHandler } from "./middleware"; +import { auth } from "./routes"; const app = new Hono(); @@ -16,6 +17,8 @@ app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); +app.route("/api/auth", auth); + const port = Number(process.env.PORT) || 3000; console.log(`Server is running on port ${port}`); diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts new file mode 100644 index 0000000..2d60636 --- /dev/null +++ b/pkgs/server/src/routes/auth.test.ts @@ -0,0 +1,148 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware"; +import { auth } from "./auth"; + +vi.mock("../db", () => { + const mockUsers: Array<{ + id: string; + username: string; + passwordHash: string; + createdAt: Date; + }> = []; + + return { + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => + Promise.resolve( + mockUsers.filter((u) => u.username === "existinguser"), + ), + ), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn((data: { username: string; passwordHash: string }) => ({ + returning: vi.fn(() => { + const newUser = { + id: "test-uuid-123", + username: data.username, + createdAt: new Date("2024-01-01T00:00:00Z"), + }; + mockUsers.push({ ...newUser, passwordHash: data.passwordHash }); + return Promise.resolve([newUser]); + }), + })), + })), + }, + users: { + id: "id", + username: "username", + createdAt: "created_at", + }, + }; +}); + +vi.mock("argon2", () => ({ + hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), +})); + +interface RegisterResponse { + user?: { + id: string; + username: string; + createdAt: string; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /register", () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("creates a new user with valid credentials", async () => { + 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", + }); + }); + + 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 () => { + const { db } = await import("../db"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: "existing-id" }]), + }), + }), + } as unknown as ReturnType<typeof db.select>); + + 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"); + }); +}); diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts new file mode 100644 index 0000000..3906d65 --- /dev/null +++ b/pkgs/server/src/routes/auth.ts @@ -0,0 +1,50 @@ +import { createUserSchema } from "@kioku/shared"; +import * as argon2 from "argon2"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { db, users } from "../db"; +import { Errors } from "../middleware"; + +const auth = new Hono(); + +auth.post("/register", async (c) => { + const body = await c.req.json(); + + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (existingUser.length > 0) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const [newUser] = await db + .insert(users) + .values({ + username, + passwordHash, + }) + .returning({ + id: users.id, + username: users.username, + createdAt: users.createdAt, + }); + + return c.json({ user: newUser }, 201); +}); + +export { auth }; diff --git a/pkgs/server/src/routes/index.ts b/pkgs/server/src/routes/index.ts new file mode 100644 index 0000000..2925e6d --- /dev/null +++ b/pkgs/server/src/routes/index.ts @@ -0,0 +1 @@ +export { auth } from "./auth"; |
