ソースを参照

RHL-029 feat(auth): include email in session management and update related tests

Code_Uwe 4 日 前
コミット
44f4f6227f

+ 3 - 2
app/api/auth/login/route.js

@@ -68,7 +68,7 @@ export const POST = withErrorHandling(
 				"Missing username or password",
 				{
 					fields: ["username", "password"],
-				}
+				},
 			);
 		}
 
@@ -104,10 +104,11 @@ export const POST = withErrorHandling(
 			userId: user._id.toString(),
 			role: user.role,
 			branchId: user.branchId ?? null,
+			email: user.email ?? null,
 		});
 
 		// Happy path response stays unchanged:
 		return json({ ok: true }, 200);
 	},
-	{ logPrefix: "[api/auth/login]" }
+	{ logPrefix: "[api/auth/login]" },
 );

+ 7 - 1
app/api/auth/login/route.test.js

@@ -1,3 +1,5 @@
+/* @vitest-environment node */
+
 import { describe, it, expect, vi, beforeEach } from "vitest";
 
 // 1) Mocks
@@ -52,6 +54,7 @@ describe("POST /api/auth/login", () => {
 		const user = {
 			_id: "507f1f77bcf86cd799439011",
 			username: "branchuser",
+			email: "nl01@example.com",
 			passwordHash: "hashed-password",
 			role: "branch",
 			branchId: "NL01",
@@ -79,13 +82,14 @@ describe("POST /api/auth/login", () => {
 
 		expect(bcryptCompare).toHaveBeenCalledWith(
 			"secret-password",
-			"hashed-password"
+			"hashed-password",
 		);
 
 		expect(createSession).toHaveBeenCalledWith({
 			userId: "507f1f77bcf86cd799439011",
 			role: "branch",
 			branchId: "NL01",
+			email: "nl01@example.com",
 		});
 	});
 
@@ -138,6 +142,7 @@ describe("POST /api/auth/login", () => {
 			exec: vi.fn().mockResolvedValue({
 				_id: "507f1f77bcf86cd799439099",
 				username: "branchuser",
+				email: "nl01@example.com",
 				// passwordHash missing on purpose
 				role: "branch",
 				branchId: "NL01",
@@ -168,6 +173,7 @@ describe("POST /api/auth/login", () => {
 		const user = {
 			_id: "507f1f77bcf86cd799439012",
 			username: "branchuser",
+			email: "nl02@example.com",
 			passwordHash: "hashed-password",
 			role: "branch",
 			branchId: "NL02",

+ 3 - 2
app/api/auth/me/route.js

@@ -35,10 +35,11 @@ export const GET = withErrorHandling(
 					userId: session.userId,
 					role: session.role,
 					branchId: session.branchId ?? null,
+					email: session.email ?? null,
 				},
 			},
-			200
+			200,
 		);
 	},
-	{ logPrefix: "[api/auth/me]" }
+	{ logPrefix: "[api/auth/me]" },
 );

+ 28 - 2
app/api/auth/me/route.test.js

@@ -26,17 +26,43 @@ describe("GET /api/auth/me", () => {
 		expect(await res.json()).toEqual({ user: null });
 	});
 
-	it("returns user payload when authenticated", async () => {
+	it("returns user payload when authenticated (includes email)", async () => {
 		getSession.mockResolvedValue({
 			userId: "u1",
 			role: "branch",
 			branchId: "NL01",
+			email: "nl01@example.com",
 		});
 
 		const res = await GET();
 		expect(res.status).toBe(200);
 		expect(await res.json()).toEqual({
-			user: { userId: "u1", role: "branch", branchId: "NL01" },
+			user: {
+				userId: "u1",
+				role: "branch",
+				branchId: "NL01",
+				email: "nl01@example.com",
+			},
+		});
+	});
+
+	it("returns email=null when session has no email", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "admin",
+			branchId: null,
+			email: null,
+		});
+
+		const res = await GET();
+		expect(res.status).toBe(200);
+		expect(await res.json()).toEqual({
+			user: {
+				userId: "u2",
+				role: "admin",
+				branchId: null,
+				email: null,
+			},
 		});
 	});
 });

+ 12 - 17
lib/auth/session.js

@@ -1,4 +1,3 @@
-// lib/auth/session.js
 import { cookies } from "next/headers";
 import { SignJWT, jwtVerify } from "jose";
 
@@ -15,16 +14,6 @@ function getSessionSecretKey() {
 	return new TextEncoder().encode(secret);
 }
 
-/**
- * Resolve whether the session cookie should be marked as "Secure".
- *
- * Default:
- * - Secure in production (`NODE_ENV=production`)
- *
- * Override (useful for local HTTP testing):
- * - SESSION_COOKIE_SECURE=false
- * - SESSION_COOKIE_SECURE=true
- */
 function resolveCookieSecureFlag() {
 	const raw = (process.env.SESSION_COOKIE_SECURE || "").trim().toLowerCase();
 	if (raw === "true") return true;
@@ -33,10 +22,18 @@ function resolveCookieSecureFlag() {
 	return process.env.NODE_ENV === "production";
 }
 
+function normalizeEmailOrNull(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim();
+	if (!s) return null;
+	// Email is not case-sensitive; keep it normalized for UI consistency.
+	return s.toLowerCase();
+}
+
 /**
  * Create a signed session JWT and store it in a HTTP-only cookie.
  */
-export async function createSession({ userId, role, branchId }) {
+export async function createSession({ userId, role, branchId, email }) {
 	if (!userId || !role) {
 		throw new Error("createSession requires userId and role");
 	}
@@ -45,6 +42,7 @@ export async function createSession({ userId, role, branchId }) {
 		userId,
 		role,
 		branchId: branchId ?? null,
+		email: normalizeEmailOrNull(email),
 	};
 
 	const jwt = await new SignJWT(payload)
@@ -82,7 +80,7 @@ export async function getSession() {
 	try {
 		const { payload } = await jwtVerify(cookie.value, secretKey);
 
-		const { userId, role, branchId } = payload;
+		const { userId, role, branchId, email } = payload;
 
 		if (typeof userId !== "string" || typeof role !== "string") {
 			return null;
@@ -92,9 +90,9 @@ export async function getSession() {
 			userId,
 			role,
 			branchId: typeof branchId === "string" ? branchId : null,
+			email: typeof email === "string" ? email : null,
 		};
 	} catch {
-		// Invalid or expired token: clear cookie and return null
 		const store = await cookies();
 		store.set(SESSION_COOKIE_NAME, "", {
 			httpOnly: true,
@@ -108,9 +106,6 @@ export async function getSession() {
 	}
 }
 
-/**
- * Destroy the current session by clearing the session cookie.
- */
 export async function destroySession() {
 	const cookieStore = await cookies();
 

+ 53 - 1
lib/auth/session.test.js

@@ -1,3 +1,5 @@
+/* @vitest-environment node */
+
 import { describe, it, expect, vi, beforeEach } from "vitest";
 
 // Mock next/headers to provide a simple in-memory cookie store
@@ -43,6 +45,7 @@ describe("auth session utilities", () => {
 		__cookieStore.clear();
 		process.env.SESSION_SECRET = "x".repeat(64);
 		process.env.NODE_ENV = "test";
+		delete process.env.SESSION_COOKIE_SECURE;
 	});
 
 	it("creates a session cookie with a signed JWT", async () => {
@@ -70,7 +73,7 @@ describe("auth session utilities", () => {
 		});
 	});
 
-	it("reads a valid session from cookie", async () => {
+	it("reads a valid session from cookie (email defaults to null)", async () => {
 		await createSession({
 			userId: "user456",
 			role: "admin",
@@ -83,6 +86,25 @@ describe("auth session utilities", () => {
 			userId: "user456",
 			role: "admin",
 			branchId: null,
+			email: null,
+		});
+	});
+
+	it("includes email in the session when provided (normalized to lowercase)", async () => {
+		await createSession({
+			userId: "user999",
+			role: "branch",
+			branchId: "NL01",
+			email: "User@Example.COM",
+		});
+
+		const session = await getSession();
+
+		expect(session).toEqual({
+			userId: "user999",
+			role: "branch",
+			branchId: "NL01",
+			email: "user@example.com",
 		});
 	});
 
@@ -143,4 +165,34 @@ describe("auth session utilities", () => {
 		expect(cookie.value).toBe("");
 		expect(cookie.options.maxAge).toBe(0);
 	});
+
+	it('respects SESSION_COOKIE_SECURE override when set to "true"', async () => {
+		process.env.SESSION_COOKIE_SECURE = "true";
+
+		await createSession({
+			userId: "user-secure",
+			role: "admin",
+			branchId: null,
+		});
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie.options.secure).toBe(true);
+	});
+
+	it('respects SESSION_COOKIE_SECURE override when set to "false"', async () => {
+		process.env.SESSION_COOKIE_SECURE = "false";
+
+		await createSession({
+			userId: "user-insecure",
+			role: "admin",
+			branchId: null,
+		});
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie.options.secure).toBe(false);
+	});
 });