feat(auth): align login and bootstrap with new user schema

This commit is contained in:
2026-06-19 01:05:33 +02:00
parent 2ed9445f7f
commit 01d89cd21b
10 changed files with 503 additions and 60 deletions
+96 -23
View File
@@ -1,4 +1,6 @@
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { getPasswordHash } from "@/lib/security" import { getPasswordHash } from "@/lib/security"
import prisma from "../src/lib/prisma" import prisma from "../src/lib/prisma"
@@ -8,10 +10,19 @@ type BootstrapAdminInput = {
password: string password: string
} }
function splitName(name: string) {
const [firstName = "Administrator", ...rest] = name.trim().split(/\s+/)
return {
firstName,
lastName: rest.join(" "),
}
}
function getBootstrapAdminInput(): BootstrapAdminInput { function getBootstrapAdminInput(): BootstrapAdminInput {
const isProduction = process.env.NODE_ENV === "production" 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 name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD const password = process.env.ADMIN_PASSWORD
@@ -28,36 +39,98 @@ function getBootstrapAdminInput(): BootstrapAdminInput {
export async function bootstrapAdmin(client: typeof prisma) { export async function bootstrapAdmin(client: typeof prisma) {
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false" 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: { where: {
role: "ADMIN", emailNormalized: email,
isActive: true,
}, },
select: { select: {
id: true, 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() if (!user.person) {
await client.person.upsert({
await client.user.upsert({ where: {
where: { userId: user.id,
email: admin.email, },
}, update: {
update: { firstName,
role: "ADMIN", lastName,
isActive: true, email: admin.email,
}, },
create: { create: {
name: admin.name, firstName,
email: admin.email, lastName,
role: "ADMIN", email: admin.email,
password: await getPasswordHash(admin.password), user: {
isActive: true, connect: {
}, id: user.id,
}) },
},
},
})
}
} }
async function main() { async function main() {
+9 -6
View File
@@ -2,11 +2,12 @@ import NextAuth, { type DefaultSession } from "next-auth"
import Credentials from "next-auth/providers/credentials" import Credentials from "next-auth/providers/credentials"
import { ZodError } from "zod" 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 { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants"
import { normalizeEmail } from "@/lib/email"
import { verifyPassword } from "@/lib/security" import { verifyPassword } from "@/lib/security"
import { signInSchema } from "@/schemas/auth.schema" import { signInSchema } from "@/schemas/auth.schema"
import { getUserByEmail } from "@/services/user.service" import { getUserCredentialsByEmail } from "@/services/user.service"
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
@@ -38,21 +39,23 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (!success) throw new Error("Invalid email or password") if (!success) throw new Error("Invalid email or password")
const user = await getUserByEmail(data.email) const user = await getUserCredentialsByEmail(normalizeEmail(data.email))
if (!user) { if (!user) {
throw new Error("Invalid email or password") throw new Error("Invalid email or password")
} }
if (!user.isActive) { if (user.status !== UserStatus.ACTIVE || !user.passwordHash) {
throw new Error("Invalid email or password") 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") throw new Error("Invalid email or password")
return { return {
...user, id: user.id,
name: user.name,
email: user.email,
role: user.role, role: user.role,
} }
} catch (error) { } catch (error) {
+3
View File
@@ -0,0 +1,3 @@
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod" import { z } from "zod"
export const signInSchema = z.object({ export const signInSchema = z.object({
email: z.email().nonempty("Email is required"), email: z.string().trim().toLowerCase().email().nonempty("Email is required"),
password: z password: z
.string() .string()
.min(3, { .min(3, {
+97 -17
View File
@@ -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 { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security" import { getPasswordHash } from "@/lib/security"
@@ -10,16 +11,21 @@ const userWithoutPasswordSelect = {
name: true, name: true,
email: true, email: true,
role: true, role: true,
isActive: true, status: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} satisfies Prisma.UserSelect } satisfies Prisma.UserSelect
export type UserWithoutPassword = Prisma.UserGetPayload<{ type UserWithoutPasswordPayload = Prisma.UserGetPayload<{
select: typeof userWithoutPasswordSelect select: typeof userWithoutPasswordSelect
}> }>
type CreateUserData = Pick<User, "name" | "email" | "password"> & { export type UserWithoutPassword = UserWithoutPasswordPayload & {
isActive: boolean
}
type CreateUserData = Pick<User, "name" | "email"> & {
password: string
role?: UserRole role?: UserRole
isActive?: boolean isActive?: boolean
} }
@@ -37,44 +43,94 @@ type GetUsersParams = {
isActive?: boolean 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( export async function createUser(
{ data }: { data: CreateUserData }, { data }: { data: CreateUserData },
db: Prisma.TransactionClient | typeof prisma = prisma, 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({ const newUser = await db.user.create({
data: { data: {
name: data.name, name: data.name,
email: data.email, email: data.email,
password: await getPasswordHash(data.password), emailNormalized: normalizeEmail(data.email),
passwordHash,
role: data.role ?? UserRole.STAFF, role: data.role ?? UserRole.STAFF,
isActive: data.isActive ?? true, status,
...(status === UserStatus.ACTIVE ? { activatedAt: now } : {}),
passwordChangedAt: now,
}, },
select: userWithoutPasswordSelect, select: userWithoutPasswordSelect,
}) })
return newUser return toUserWithoutPassword(newUser)
} }
export async function getUserById( export async function getUserById(
id: string, id: string,
db: Prisma.TransactionClient | typeof prisma = prisma, 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( export async function getUserProfileById(
id: string, id: string,
): Promise<UserWithoutPassword | null> { ): Promise<UserWithoutPassword | null> {
return prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id }, where: { id },
select: userWithoutPasswordSelect, select: userWithoutPasswordSelect,
}) })
return user ? toUserWithoutPassword(user) : null
} }
export async function getUserByEmail( export async function getUserByEmail(
email: string, email: string,
db: Prisma.TransactionClient | typeof prisma = prisma, 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({ export async function getUsers({
@@ -84,12 +140,14 @@ export async function getUsers({
role, role,
isActive, isActive,
}: GetUsersParams = {}): Promise<PaginatedResult<UserWithoutPassword>> { }: GetUsersParams = {}): Promise<PaginatedResult<UserWithoutPassword>> {
return paginate<UserWithoutPassword>({ const users = await paginate<UserWithoutPasswordPayload>({
model: prisma.user, model: prisma.user,
page, page,
pageSize, pageSize,
where: { where: {
...(typeof isActive === "boolean" ? { isActive } : {}), ...(typeof isActive === "boolean"
? { status: userStatusFromActive(isActive) }
: {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(search ...(search
? { ? {
@@ -103,6 +161,11 @@ export async function getUsers({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
select: userWithoutPasswordSelect, select: userWithoutPasswordSelect,
}) })
return {
...users,
data: users.data.map(toUserWithoutPassword),
}
} }
export async function updateUser( export async function updateUser(
@@ -110,11 +173,25 @@ export async function updateUser(
data: UpdateUserData, data: UpdateUserData,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> { ): Promise<UserWithoutPassword> {
return db.user.update({ const user = await db.user.update({
where: { id }, 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, select: userWithoutPasswordSelect,
}) })
return toUserWithoutPassword(user)
} }
export async function updateUserRole( export async function updateUserRole(
@@ -137,13 +214,16 @@ export async function resetUserPassword(
password: string, password: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> { ): Promise<UserWithoutPassword> {
return db.user.update({ const user = await db.user.update({
where: { id }, where: { id },
data: { data: {
password: await getPasswordHash(password), passwordHash: await getPasswordHash(password),
passwordChangedAt: new Date(),
}, },
select: userWithoutPasswordSelect, select: userWithoutPasswordSelect,
}) })
return toUserWithoutPassword(user)
} }
export async function countActiveAdmins( export async function countActiveAdmins(
@@ -152,7 +232,7 @@ export async function countActiveAdmins(
return db.user.count({ return db.user.count({
where: { where: {
role: UserRole.ADMIN, role: UserRole.ADMIN,
isActive: true, status: UserStatus.ACTIVE,
}, },
}) })
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@/generated/prisma/client" import { Prisma, UserStatus } from "@/generated/prisma/client"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import type { import type {
CreateUserFormType, CreateUserFormType,
@@ -96,7 +96,7 @@ async function getAdminAccessLossError(
if ( if (
user.role === "ADMIN" && user.role === "ADMIN" &&
user.isActive && user.status === UserStatus.ACTIVE &&
(await countActiveAdmins(db)) <= 1 (await countActiveAdmins(db)) <= 1
) { ) {
return "Cannot remove access from the last active administrator" return "Cannot remove access from the last active administrator"
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client" import type { PrismaClient } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { createTestUser } from "../helpers/factories" import { createTestUser } from "../helpers/factories"
import { import {
resetIntegrationTestDatabase, resetIntegrationTestDatabase,
@@ -51,18 +52,21 @@ describe("user use-cases", () => {
expect(result).toEqual({ success: true }) expect(result).toEqual({ success: true })
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { email: "new-user@example.test" }, where: { emailNormalized: normalizeEmail("new-user@example.test") },
}) })
expect(user).toMatchObject({ expect(user).toMatchObject({
name: "New User", name: "New User",
email: "new-user@example.test", email: "new-user@example.test",
role: "STAFF", 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( await expect(
verifyPassword("secure-password", user.password), verifyPassword("secure-password", user.passwordHash),
).resolves.toBe(true) ).resolves.toBe(true)
}) })
@@ -87,7 +91,7 @@ describe("user use-cases", () => {
await expect(prisma.user.count()).resolves.toBe(1) await expect(prisma.user.count()).resolves.toBe(1)
await expect( await expect(
prisma.user.findUniqueOrThrow({ prisma.user.findUniqueOrThrow({
where: { email: "existing@example.test" }, where: { emailNormalized: normalizeEmail("existing@example.test") },
}), }),
).resolves.toMatchObject({ email: "existing@example.test" }) ).resolves.toMatchObject({ email: "existing@example.test" })
}) })
@@ -120,7 +124,7 @@ describe("user use-cases", () => {
name: "Edited User", name: "Edited User",
email: "edited@example.test", email: "edited@example.test",
role: "MANAGER", role: "MANAGER",
isActive: true, status: "ACTIVE",
}) })
await expect( await expect(
@@ -163,7 +167,7 @@ describe("user use-cases", () => {
await expect( await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }), 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 () => { 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( await expect(
prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }), prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).resolves.toMatchObject({ isActive: false }) ).resolves.toMatchObject({ status: "DISABLED" })
}) })
it("prevents self-deactivation", async () => { it("prevents self-deactivation", async () => {
@@ -224,7 +228,7 @@ describe("user use-cases", () => {
await expect( await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }), 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 () => { it("resets a user password and rejects missing users", async () => {
@@ -244,9 +248,10 @@ describe("user use-cases", () => {
where: { id: user.id }, 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( await expect(
verifyPassword("new-secure-password", updatedUser.password), verifyPassword("new-secure-password", updatedUser.passwordHash),
).resolves.toBe(true) ).resolves.toBe(true)
await expect( await expect(
+164
View File
@@ -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",
},
}),
)
})
})
+17
View File
@@ -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")
}
})
})
+98
View File
@@ -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,
}),
})
})
})