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:
180
src/lib/__tests__/auth.test.ts
Normal file
180
src/lib/__tests__/auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user