feat(01-02): login page UI, logout flow, seed script, and auth unit tests

- src/app/(auth)/layout.tsx: centered auth layout for login page
- src/app/(auth)/login/page.tsx: login form with error/loading states, sign up link
- src/components/providers.tsx: SessionProvider wrapper for client-side session
- src/components/layout/header.tsx: authenticated header with Sign out button
- src/app/(dashboard)/layout.tsx: dashboard layout wrapping Header component
- src/app/(dashboard)/dashboard/page.tsx: basic dashboard page post-login
- src/app/layout.tsx: wrap root with SessionProvider via Providers component
- prisma/seed.ts: idempotent seed for Demo ISP tenant + admin + super-admin users
- package.json: add db:seed script and prisma.seed config, add tsx devDep
- src/lib/__tests__/auth.test.ts: 8 unit tests for authOptions callbacks
This commit is contained in:
kevin-asprec
2026-03-04 18:42:53 +08:00
parent 43761d94ee
commit 3c37cb1866
10 changed files with 895 additions and 4 deletions

View File

@@ -0,0 +1,180 @@
/**
* Unit tests for NextAuth configuration (auth-options.ts).
*
* These tests verify that the authOptions object is correctly structured
* and that the JWT/session callbacks properly propagate tenantId and roles.
* They do NOT require a running server or database.
*/
import { authOptions } from "@/lib/auth-options";
import type { JWT } from "next-auth/jwt";
import type { Session } from "next-auth";
import type { Role } from "@prisma/client";
describe("authOptions", () => {
describe("configuration", () => {
it("should have credentials provider configured", () => {
expect(authOptions.providers).toBeDefined();
expect(authOptions.providers.length).toBeGreaterThan(0);
const credentialsProvider = authOptions.providers.find(
(p) => p.id === "credentials"
);
expect(credentialsProvider).toBeDefined();
});
it("should use JWT session strategy", () => {
expect(authOptions.session?.strategy).toBe("jwt");
});
it("should set session maxAge to 24 hours", () => {
expect(authOptions.session?.maxAge).toBe(24 * 60 * 60);
});
it("should have signIn page set to /login", () => {
expect(authOptions.pages?.signIn).toBe("/login");
});
});
describe("JWT callback", () => {
it("should persist user fields into the JWT token on initial sign-in", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
expect(jwtCallback).toBeDefined();
if (!jwtCallback) return;
const mockUser = {
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
};
const mockToken = { sub: "user-123" } as JWT;
// Cast through unknown to avoid strict NextAuth type checking in tests
const result = await jwtCallback({
token: mockToken,
user: mockUser as unknown as Parameters<typeof jwtCallback>[0]["user"],
account: null,
trigger: "signIn",
});
expect(result.id).toBe("user-123");
expect(result.email).toBe("admin@demo.com");
expect(result.tenantId).toBe("tenant-abc");
expect(result.roles).toEqual(["ADMIN"]);
expect(result.isSuperAdmin).toBe(false);
expect(result.firstName).toBe("Demo");
expect(result.lastName).toBe("Admin");
});
it("should preserve existing token fields when user is not present (token refresh)", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
if (!jwtCallback) return;
const existingToken = {
sub: "user-123",
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
} as JWT;
const result = await jwtCallback({
token: existingToken,
user: null as unknown as Parameters<typeof jwtCallback>[0]["user"],
account: null,
trigger: "update",
});
expect(result.tenantId).toBe("tenant-abc");
expect(result.roles).toEqual(["ADMIN"]);
});
it("should handle super-admin with null tenantId", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
if (!jwtCallback) return;
const superAdminUser = {
id: "superadmin-1",
email: "superadmin@netforge.com",
tenantId: null,
roles: [] as Role[],
isSuperAdmin: true,
firstName: "Super",
lastName: "Admin",
};
const result = await jwtCallback({
token: {} as JWT,
user: superAdminUser as unknown as Parameters<
typeof jwtCallback
>[0]["user"],
account: null,
trigger: "signIn",
});
expect(result.tenantId).toBeNull();
expect(result.isSuperAdmin).toBe(true);
});
});
describe("session callback", () => {
it("should expose JWT fields on session.user", async () => {
const sessionCallback = authOptions.callbacks?.session;
expect(sessionCallback).toBeDefined();
if (!sessionCallback) return;
const mockToken = {
sub: "user-123",
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
} as JWT;
const mockSession: Session = {
user: {
id: "",
email: "",
tenantId: null,
roles: [],
isSuperAdmin: false,
firstName: "",
lastName: "",
},
expires: new Date(Date.now() + 86400000).toISOString(),
};
const result = await sessionCallback({
session: mockSession,
token: mockToken,
user: undefined as unknown as Parameters<
typeof sessionCallback
>[0]["user"],
newSession: undefined,
trigger: "update",
});
// Type the result session.user through our extended Session type
const user = result.user as Session["user"];
expect(user.id).toBe("user-123");
expect(user.email).toBe("admin@demo.com");
expect(user.tenantId).toBe("tenant-abc");
expect(user.roles).toEqual(["ADMIN"]);
expect(user.isSuperAdmin).toBe(false);
expect(user.firstName).toBe("Demo");
expect(user.lastName).toBe("Admin");
});
});
});