This commit is contained in:
admin
2026-09-03 22:02:52 +09:00
commit 659725de15
90 changed files with 2840 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import { and, eq } from "drizzle-orm";
import type { DatabaseContext } from "../db/database";
import { sessions, users } from "../db/schema";
export async function createSession(context: DatabaseContext, userId: string, expiresAt: string) {
const session = {
id: crypto.randomUUID(),
userId,
createdAt: new Date().toISOString(),
expiresAt
};
await context.db.insert(sessions).values(session);
return session;
}
export async function findSessionUser(context: DatabaseContext, sessionId: string) {
const [row] = await context.db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId));
if (!row) {
return null;
}
if (new Date(row.session.expiresAt).getTime() <= Date.now()) {
await deleteSession(context, sessionId);
return null;
}
return {
id: row.user.id,
username: row.user.username,
name: row.user.name,
role: row.user.role as "customer" | "employee"
};
}
export async function deleteSession(context: DatabaseContext, sessionId: string) {
await context.db.delete(sessions).where(eq(sessions.id, sessionId));
}
export async function deleteUserSessions(context: DatabaseContext, userId: string) {
await context.db.delete(sessions).where(eq(sessions.userId, userId));
}
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AppWithContext } from "../app";
import { buildApp } from "../app";
let app: AppWithContext;
function firstHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value ?? "";
}
beforeEach(async () => {
app = await buildApp(":memory:");
});
afterEach(async () => {
await app.close();
});
describe("auth routes", () => {
test("returns null current user when logged out", async () => {
const response = await app.inject({ method: "GET", url: "/api/me" });
expect(response.statusCode).toBe(200);
expect(response.json().user).toBeNull();
});
test("logs in seeded customer by username", async () => {
const response = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
expect(response.statusCode).toBe(200);
expect(response.json().user.username).toBe("john-smith");
expect(firstHeader(response.headers["set-cookie"])).toContain("orderops_session=");
});
test("returns current user from session cookie", async () => {
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
const cookie = firstHeader(login.headers["set-cookie"]);
const response = await app.inject({ method: "GET", url: "/api/me", headers: { cookie } });
expect(response.statusCode).toBe(200);
expect(response.json().user.role).toBe("customer");
});
test("logs out and clears cookie", async () => {
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
const cookie = firstHeader(login.headers["set-cookie"]);
const response = await app.inject({ method: "POST", url: "/api/logout", headers: { cookie } });
expect(response.statusCode).toBe(200);
expect(firstHeader(response.headers["set-cookie"])).toContain("Max-Age=0");
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyPluginAsync } from "fastify";
import type { DatabaseContext } from "../db/database";
import { createSession, deleteSession } from "./auth-repository";
import { clearSessionCookie, createSessionCookie, readSessionCookie } from "./session-cookie";
import { getCurrentUser } from "./current-user";
import { findUserByUsername } from "../users/user-repository";
interface AuthRouteOptions {
db: DatabaseContext;
}
interface LoginBody {
username: string;
}
export const registerAuthRoutes: FastifyPluginAsync<AuthRouteOptions> = async (app, options) => {
app.get("/me", async (request) => ({ user: await getCurrentUser(options.db, request.headers.cookie) }));
app.post<{ Body: LoginBody }>("/login", {
schema: {
body: {
type: "object",
required: ["username"],
properties: {
username: { type: "string", minLength: 1 }
}
}
}
}, async (request, reply) => {
const user = await findUserByUsername(options.db, request.body.username.trim());
if (!user) {
return reply.code(401).send({ message: "Invalid username." });
}
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString();
const session = await createSession(options.db, user.id, expiresAt);
reply.header("set-cookie", createSessionCookie(session.id, expiresAt));
return { user };
});
app.post("/logout", async (request, reply) => {
const sessionId = readSessionCookie(request.headers.cookie);
if (sessionId) {
await deleteSession(options.db, sessionId);
}
reply.header("set-cookie", clearSessionCookie());
return { ok: true };
});
};
+31
View File
@@ -0,0 +1,31 @@
import type { AuthUser } from "@orderops/shared";
import type { FastifyReply, FastifyRequest } from "fastify";
import type { DatabaseContext } from "../db/database";
import { findSessionUser } from "./auth-repository";
import { readSessionCookie } from "./session-cookie";
export async function getCurrentUser(context: DatabaseContext, cookieHeader?: string) {
const sessionId = readSessionCookie(cookieHeader);
if (!sessionId) {
return null;
}
return findSessionUser(context, sessionId);
}
export async function requireEmployeeUser(context: DatabaseContext, request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
const user = await getCurrentUser(context, request.headers.cookie);
if (!user) {
await reply.code(401).send({ message: "Authentication required." });
return null;
}
if (user.role !== "employee") {
await reply.code(403).send({ message: "Employee access required." });
return null;
}
return user;
}
+25
View File
@@ -0,0 +1,25 @@
const sessionCookieName = "orderops_session";
export function readSessionCookie(cookieHeader?: string) {
if (!cookieHeader) {
return null;
}
for (const cookie of cookieHeader.split(";")) {
const [name, ...valueParts] = cookie.trim().split("=");
if (name === sessionCookieName) {
return valueParts.join("=") || null;
}
}
return null;
}
export function createSessionCookie(sessionId: string, expiresAt: string) {
return `${sessionCookieName}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}`;
}
export function clearSessionCookie() {
return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}