diff --git a/prisma/bootstrap-admin.ts b/prisma/bootstrap-admin.ts index 58bd947..ece1601 100644 --- a/prisma/bootstrap-admin.ts +++ b/prisma/bootstrap-admin.ts @@ -1,4 +1,6 @@ import { fileURLToPath } from "node:url" +import { UserStatus } from "@/generated/prisma/client" +import { normalizeEmail } from "@/lib/email" import { getPasswordHash } from "@/lib/security" import prisma from "../src/lib/prisma" @@ -8,10 +10,19 @@ type BootstrapAdminInput = { password: string } +function splitName(name: string) { + const [firstName = "Administrator", ...rest] = name.trim().split(/\s+/) + + return { + firstName, + lastName: rest.join(" "), + } +} + function getBootstrapAdminInput(): BootstrapAdminInput { const isProduction = process.env.NODE_ENV === "production" - const email = process.env.ADMIN_EMAIL ?? "admin@localhost" + const email = process.env.ADMIN_EMAIL ?? "admin@local.host" const name = process.env.ADMIN_NAME ?? "Administrator" const password = process.env.ADMIN_PASSWORD @@ -28,36 +39,98 @@ function getBootstrapAdminInput(): BootstrapAdminInput { export async function bootstrapAdmin(client: typeof prisma) { const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false" - const existingAdmin = await client.user.findFirst({ + if (!enabled) return + + const admin = getBootstrapAdminInput() + const email = normalizeEmail(admin.email) + const { firstName, lastName } = splitName(admin.name) + const existingUser = await client.user.findUnique({ where: { - role: "ADMIN", - isActive: true, + emailNormalized: email, }, select: { id: true, + passwordHash: true, + activatedAt: true, + person: { + select: { + id: true, + }, + }, }, }) - if (existingAdmin || !enabled) return + const user = existingUser + ? await client.user.update({ + where: { + id: existingUser.id, + }, + data: { + name: admin.name, + email: admin.email, + emailNormalized: email, + role: "ADMIN", + status: UserStatus.ACTIVE, + ...(existingUser.passwordHash + ? {} + : { + passwordHash: await getPasswordHash(admin.password), + passwordChangedAt: new Date(), + }), + ...(existingUser.activatedAt ? {} : { activatedAt: new Date() }), + }, + select: { + id: true, + person: { + select: { + id: true, + }, + }, + }, + }) + : await client.user.create({ + data: { + name: admin.name, + email: admin.email, + emailNormalized: email, + role: "ADMIN", + status: UserStatus.ACTIVE, + passwordHash: await getPasswordHash(admin.password), + activatedAt: new Date(), + passwordChangedAt: new Date(), + }, + select: { + id: true, + person: { + select: { + id: true, + }, + }, + }, + }) - const admin = getBootstrapAdminInput() - - await client.user.upsert({ - where: { - email: admin.email, - }, - update: { - role: "ADMIN", - isActive: true, - }, - create: { - name: admin.name, - email: admin.email, - role: "ADMIN", - password: await getPasswordHash(admin.password), - isActive: true, - }, - }) + if (!user.person) { + await client.person.upsert({ + where: { + userId: user.id, + }, + update: { + firstName, + lastName, + email: admin.email, + }, + create: { + firstName, + lastName, + email: admin.email, + user: { + connect: { + id: user.id, + }, + }, + }, + }) + } } async function main() { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b67fc55..5b34d52 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,11 +2,12 @@ import NextAuth, { type DefaultSession } from "next-auth" import Credentials from "next-auth/providers/credentials" import { ZodError } from "zod" -import type { UserRole } from "@/generated/prisma/client" +import { type UserRole, UserStatus } from "@/generated/prisma/client" import { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants" +import { normalizeEmail } from "@/lib/email" import { verifyPassword } from "@/lib/security" import { signInSchema } from "@/schemas/auth.schema" -import { getUserByEmail } from "@/services/user.service" +import { getUserCredentialsByEmail } from "@/services/user.service" declare module "next-auth" { interface Session { @@ -38,21 +39,23 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (!success) throw new Error("Invalid email or password") - const user = await getUserByEmail(data.email) + const user = await getUserCredentialsByEmail(normalizeEmail(data.email)) if (!user) { throw new Error("Invalid email or password") } - if (!user.isActive) { + if (user.status !== UserStatus.ACTIVE || !user.passwordHash) { throw new Error("Invalid email or password") } - if (!(await verifyPassword(data.password, user.password))) + if (!(await verifyPassword(data.password, user.passwordHash))) throw new Error("Invalid email or password") return { - ...user, + id: user.id, + name: user.name, + email: user.email, role: user.role, } } catch (error) { diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..f23c326 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,3 @@ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 1f0513c..93bc665 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod" export const signInSchema = z.object({ - email: z.email().nonempty("Email is required"), + email: z.string().trim().toLowerCase().email().nonempty("Email is required"), password: z .string() .min(3, { diff --git a/src/services/user.service.ts b/src/services/user.service.ts index fead9a3..8129ffc 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,4 +1,5 @@ -import { type Prisma, UserRole } from "@/generated/prisma/client" +import { type Prisma, UserRole, UserStatus } from "@/generated/prisma/client" +import { normalizeEmail } from "@/lib/email" import { paginate } from "@/lib/paginate" import prisma from "@/lib/prisma" import { getPasswordHash } from "@/lib/security" @@ -10,16 +11,21 @@ const userWithoutPasswordSelect = { name: true, email: true, role: true, - isActive: true, + status: true, createdAt: true, updatedAt: true, } satisfies Prisma.UserSelect -export type UserWithoutPassword = Prisma.UserGetPayload<{ +type UserWithoutPasswordPayload = Prisma.UserGetPayload<{ select: typeof userWithoutPasswordSelect }> -type CreateUserData = Pick & { +export type UserWithoutPassword = UserWithoutPasswordPayload & { + isActive: boolean +} + +type CreateUserData = Pick & { + password: string role?: UserRole isActive?: boolean } @@ -37,44 +43,94 @@ type GetUsersParams = { isActive?: boolean } +function userStatusFromActive(isActive: boolean | undefined) { + if (typeof isActive !== "boolean") return undefined + return isActive ? UserStatus.ACTIVE : UserStatus.DISABLED +} + +function toUserWithoutPassword( + user: UserWithoutPasswordPayload, +): UserWithoutPassword { + return { + ...user, + isActive: user.status === UserStatus.ACTIVE, + } +} + export async function createUser( { data }: { data: CreateUserData }, db: Prisma.TransactionClient | typeof prisma = prisma, ) { + const status = userStatusFromActive(data.isActive) ?? UserStatus.ACTIVE + const passwordHash = await getPasswordHash(data.password) + const now = new Date() + const newUser = await db.user.create({ data: { name: data.name, email: data.email, - password: await getPasswordHash(data.password), + emailNormalized: normalizeEmail(data.email), + passwordHash, role: data.role ?? UserRole.STAFF, - isActive: data.isActive ?? true, + status, + ...(status === UserStatus.ACTIVE ? { activatedAt: now } : {}), + passwordChangedAt: now, }, select: userWithoutPasswordSelect, }) - return newUser + return toUserWithoutPassword(newUser) } export async function getUserById( id: string, db: Prisma.TransactionClient | typeof prisma = prisma, ) { - return await db.user.findUnique({ where: { id } }) + const user = await db.user.findUnique({ + where: { id }, + select: userWithoutPasswordSelect, + }) + + return user ? toUserWithoutPassword(user) : null } export async function getUserProfileById( id: string, ): Promise { - return prisma.user.findUnique({ + const user = await prisma.user.findUnique({ where: { id }, select: userWithoutPasswordSelect, }) + + return user ? toUserWithoutPassword(user) : null } export async function getUserByEmail( email: string, db: Prisma.TransactionClient | typeof prisma = prisma, ) { - return await db.user.findUnique({ where: { email } }) + const user = await db.user.findUnique({ + where: { emailNormalized: normalizeEmail(email) }, + select: userWithoutPasswordSelect, + }) + + return user ? toUserWithoutPassword(user) : null +} + +export async function getUserCredentialsByEmail( + email: string, + db: Prisma.TransactionClient | typeof prisma = prisma, +) { + return await db.user.findUnique({ + where: { emailNormalized: normalizeEmail(email) }, + select: { + id: true, + name: true, + email: true, + role: true, + status: true, + passwordHash: true, + }, + }) } export async function getUsers({ @@ -84,12 +140,14 @@ export async function getUsers({ role, isActive, }: GetUsersParams = {}): Promise> { - return paginate({ + const users = await paginate({ model: prisma.user, page, pageSize, where: { - ...(typeof isActive === "boolean" ? { isActive } : {}), + ...(typeof isActive === "boolean" + ? { status: userStatusFromActive(isActive) } + : {}), ...(role ? { role } : {}), ...(search ? { @@ -103,6 +161,11 @@ export async function getUsers({ orderBy: { createdAt: "desc" }, select: userWithoutPasswordSelect, }) + + return { + ...users, + data: users.data.map(toUserWithoutPassword), + } } export async function updateUser( @@ -110,11 +173,25 @@ export async function updateUser( data: UpdateUserData, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise { - return db.user.update({ + const user = await db.user.update({ where: { id }, - data, + data: { + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.email !== undefined + ? { email: data.email, emailNormalized: normalizeEmail(data.email) } + : {}), + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.isActive !== undefined + ? { + status: userStatusFromActive(data.isActive), + ...(data.isActive ? { activatedAt: new Date() } : {}), + } + : {}), + }, select: userWithoutPasswordSelect, }) + + return toUserWithoutPassword(user) } export async function updateUserRole( @@ -137,13 +214,16 @@ export async function resetUserPassword( password: string, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise { - return db.user.update({ + const user = await db.user.update({ where: { id }, data: { - password: await getPasswordHash(password), + passwordHash: await getPasswordHash(password), + passwordChangedAt: new Date(), }, select: userWithoutPasswordSelect, }) + + return toUserWithoutPassword(user) } export async function countActiveAdmins( @@ -152,7 +232,7 @@ export async function countActiveAdmins( return db.user.count({ where: { role: UserRole.ADMIN, - isActive: true, + status: UserStatus.ACTIVE, }, }) } diff --git a/src/use-cases/user.use-cases.ts b/src/use-cases/user.use-cases.ts index b022c59..786f5bc 100644 --- a/src/use-cases/user.use-cases.ts +++ b/src/use-cases/user.use-cases.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@/generated/prisma/client" +import { Prisma, UserStatus } from "@/generated/prisma/client" import prisma from "@/lib/prisma" import type { CreateUserFormType, @@ -96,7 +96,7 @@ async function getAdminAccessLossError( if ( user.role === "ADMIN" && - user.isActive && + user.status === UserStatus.ACTIVE && (await countActiveAdmins(db)) <= 1 ) { return "Cannot remove access from the last active administrator" diff --git a/tests/integration/use-cases/user.use-cases.test.ts b/tests/integration/use-cases/user.use-cases.test.ts index a6a1f95..24a7a25 100644 --- a/tests/integration/use-cases/user.use-cases.test.ts +++ b/tests/integration/use-cases/user.use-cases.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import type { PrismaClient } from "@/generated/prisma/client" +import { normalizeEmail } from "@/lib/email" import { createTestUser } from "../helpers/factories" import { resetIntegrationTestDatabase, @@ -51,18 +52,21 @@ describe("user use-cases", () => { expect(result).toEqual({ success: true }) const user = await prisma.user.findUniqueOrThrow({ - where: { email: "new-user@example.test" }, + where: { emailNormalized: normalizeEmail("new-user@example.test") }, }) expect(user).toMatchObject({ name: "New User", email: "new-user@example.test", role: "STAFF", - isActive: true, + status: "ACTIVE", }) - expect(user.password).not.toBe("secure-password") + expect(user.activatedAt).toBeInstanceOf(Date) + expect(user.passwordChangedAt).toBeInstanceOf(Date) + expect(user.passwordHash).not.toBe("secure-password") + if (!user.passwordHash) throw new Error("Expected password hash") await expect( - verifyPassword("secure-password", user.password), + verifyPassword("secure-password", user.passwordHash), ).resolves.toBe(true) }) @@ -87,7 +91,7 @@ describe("user use-cases", () => { await expect(prisma.user.count()).resolves.toBe(1) await expect( prisma.user.findUniqueOrThrow({ - where: { email: "existing@example.test" }, + where: { emailNormalized: normalizeEmail("existing@example.test") }, }), ).resolves.toMatchObject({ email: "existing@example.test" }) }) @@ -120,7 +124,7 @@ describe("user use-cases", () => { name: "Edited User", email: "edited@example.test", role: "MANAGER", - isActive: true, + status: "ACTIVE", }) await expect( @@ -163,7 +167,7 @@ describe("user use-cases", () => { await expect( prisma.user.findUniqueOrThrow({ where: { id: admin.id } }), - ).resolves.toMatchObject({ role: "ADMIN", isActive: true }) + ).resolves.toMatchObject({ role: "ADMIN", status: "ACTIVE" }) }) it("protects the last active administrator but allows deactivation when another active admin exists", async () => { @@ -204,7 +208,7 @@ describe("user use-cases", () => { await expect( prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }), - ).resolves.toMatchObject({ isActive: false }) + ).resolves.toMatchObject({ status: "DISABLED" }) }) it("prevents self-deactivation", async () => { @@ -224,7 +228,7 @@ describe("user use-cases", () => { await expect( prisma.user.findUniqueOrThrow({ where: { id: admin.id } }), - ).resolves.toMatchObject({ isActive: true }) + ).resolves.toMatchObject({ status: "ACTIVE" }) }) it("resets a user password and rejects missing users", async () => { @@ -244,9 +248,10 @@ describe("user use-cases", () => { where: { id: user.id }, }) - expect(updatedUser.password).not.toBe(user.password) + expect(updatedUser.passwordHash).not.toBe(user.passwordHash) + if (!updatedUser.passwordHash) throw new Error("Expected password hash") await expect( - verifyPassword("new-secure-password", updatedUser.password), + verifyPassword("new-secure-password", updatedUser.passwordHash), ).resolves.toBe(true) await expect( diff --git a/tests/unit/prisma/bootstrap-admin.test.ts b/tests/unit/prisma/bootstrap-admin.test.ts new file mode 100644 index 0000000..efb8cb3 --- /dev/null +++ b/tests/unit/prisma/bootstrap-admin.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getPasswordHash: vi.fn(), +})) + +vi.mock("@/lib/security", () => ({ + getPasswordHash: mocks.getPasswordHash, +})) + +vi.mock("../../../src/lib/prisma", () => ({ + default: {}, +})) + +import { bootstrapAdmin } from "../../../prisma/bootstrap-admin" + +describe("bootstrapAdmin", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.ADMIN_BOOTSTRAP_ENABLED = "true" + process.env.ADMIN_EMAIL = "Admin@Example.Test" + process.env.ADMIN_NAME = "E2E Admin" + process.env.ADMIN_PASSWORD = "admin-password" + vi.stubEnv("NODE_ENV", "development") + mocks.getPasswordHash.mockResolvedValue("hashed-password") + }) + + it("creates an active user and links a person on first run", async () => { + const userFindUnique = vi.fn().mockResolvedValue(null) + const userCreate = vi.fn().mockResolvedValue({ + id: "user-1", + person: null, + }) + const userUpdate = vi.fn() + const personUpsert = vi.fn().mockResolvedValue({ id: "person-1" }) + + const client = { + user: { + findUnique: userFindUnique, + create: userCreate, + update: userUpdate, + }, + person: { + upsert: personUpsert, + }, + } + + await bootstrapAdmin(client as never) + + expect(userFindUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + emailNormalized: "admin@example.test", + }, + }), + ) + expect(userCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + emailNormalized: "admin@example.test", + passwordHash: "hashed-password", + status: "ACTIVE", + }), + }), + ) + expect(userUpdate).not.toHaveBeenCalled() + expect(personUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + }, + create: expect.objectContaining({ + firstName: "E2E", + lastName: "Admin", + email: "Admin@Example.Test", + }), + update: expect.objectContaining({ + firstName: "E2E", + lastName: "Admin", + email: "Admin@Example.Test", + }), + }), + ) + }) + + it("is idempotent when the admin already has a linked person", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ + id: "user-1", + passwordHash: "existing-hash", + activatedAt: new Date("2024-01-01T00:00:00.000Z"), + person: { id: "person-1" }, + }) + const userCreate = vi.fn() + const userUpdate = vi.fn().mockResolvedValue({ + id: "user-1", + person: { id: "person-1" }, + }) + const personUpsert = vi.fn() + + const client = { + user: { + findUnique: userFindUnique, + create: userCreate, + update: userUpdate, + }, + person: { + upsert: personUpsert, + }, + } + + await bootstrapAdmin(client as never) + + expect(mocks.getPasswordHash).not.toHaveBeenCalled() + expect(userCreate).not.toHaveBeenCalled() + expect(userUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ + passwordHash: expect.any(String), + activatedAt: expect.any(Date), + passwordChangedAt: expect.any(Date), + }), + }), + ) + expect(personUpsert).not.toHaveBeenCalled() + }) + + it("links a missing person without rehashing an existing admin password", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ + id: "user-1", + passwordHash: "existing-hash", + activatedAt: new Date("2024-01-01T00:00:00.000Z"), + person: null, + }) + const userCreate = vi.fn() + const userUpdate = vi.fn().mockResolvedValue({ + id: "user-1", + person: null, + }) + const personUpsert = vi.fn().mockResolvedValue({ id: "person-1" }) + + const client = { + user: { + findUnique: userFindUnique, + create: userCreate, + update: userUpdate, + }, + person: { + upsert: personUpsert, + }, + } + + await bootstrapAdmin(client as never) + + expect(mocks.getPasswordHash).not.toHaveBeenCalled() + expect(userCreate).not.toHaveBeenCalled() + expect(personUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + }, + }), + ) + }) +}) diff --git a/tests/unit/schemas/auth.schema.test.ts b/tests/unit/schemas/auth.schema.test.ts new file mode 100644 index 0000000..86f5f36 --- /dev/null +++ b/tests/unit/schemas/auth.schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" + +import { signInSchema } from "@/schemas/auth.schema" + +describe("signInSchema", () => { + it("normalizes login emails before authentication", () => { + const result = signInSchema.safeParse({ + email: " Admin@Example.Test ", + password: "secret-password", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.email).toBe("admin@example.test") + } + }) +}) diff --git a/tests/unit/services/user.service.test.ts b/tests/unit/services/user.service.test.ts new file mode 100644 index 0000000..9b5d8ca --- /dev/null +++ b/tests/unit/services/user.service.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest" + +vi.mock("@/lib/prisma", () => ({ + default: {}, +})) + +import { + getUserById, + getUserByEmail, + getUserCredentialsByEmail, +} from "@/services/user.service" + +describe("getUserById", () => { + it("does not select passwordHash across the broad user lookup boundary", async () => { + const findUnique = vi.fn().mockResolvedValue(null) + const db = { + user: { + findUnique, + }, + } + + await getUserById("user-1", db as never) + + expect(findUnique).toHaveBeenCalledWith({ + where: { + id: "user-1", + }, + select: expect.not.objectContaining({ + passwordHash: true, + }), + }) + }) +}) + +describe("getUserByEmail", () => { + it("queries emailNormalized with a normalized email", async () => { + const findUnique = vi.fn().mockResolvedValue(null) + const db = { + user: { + findUnique, + }, + } + + await getUserByEmail(" Admin@Example.Test ", db as never) + + expect(findUnique).toHaveBeenCalledWith({ + where: { + emailNormalized: "admin@example.test", + }, + select: expect.not.objectContaining({ + passwordHash: true, + }), + }) + }) + + it("does not return passwordHash across the broad user lookup boundary", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user-1", + name: "Admin", + email: "admin@example.test", + role: "ADMIN", + status: "ACTIVE", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }) + const db = { + user: { + findUnique, + }, + } + + const user = await getUserByEmail("admin@example.test", db as never) + + expect(user).not.toHaveProperty("passwordHash") + }) +}) + +describe("getUserCredentialsByEmail", () => { + it("selects passwordHash only for credential verification", async () => { + const findUnique = vi.fn().mockResolvedValue(null) + const db = { + user: { + findUnique, + }, + } + + await getUserCredentialsByEmail("Admin@Example.Test", db as never) + + expect(findUnique).toHaveBeenCalledWith({ + where: { + emailNormalized: "admin@example.test", + }, + select: expect.objectContaining({ + passwordHash: true, + }), + }) + }) +})