feat(auth): align login and bootstrap with new user schema
This commit is contained in:
+90
-17
@@ -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 admin = getBootstrapAdminInput()
|
||||
|
||||
await client.user.upsert({
|
||||
const user = existingUser
|
||||
? await client.user.update({
|
||||
where: {
|
||||
email: admin.email,
|
||||
id: existingUser.id,
|
||||
},
|
||||
update: {
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
data: {
|
||||
name: admin.name,
|
||||
email: admin.email,
|
||||
emailNormalized: email,
|
||||
role: "ADMIN",
|
||||
password: await getPasswordHash(admin.password),
|
||||
isActive: true,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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() {
|
||||
|
||||
+9
-6
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<User, "name" | "email" | "password"> & {
|
||||
export type UserWithoutPassword = UserWithoutPasswordPayload & {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
type CreateUserData = Pick<User, "name" | "email"> & {
|
||||
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<UserWithoutPassword | null> {
|
||||
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<PaginatedResult<UserWithoutPassword>> {
|
||||
return paginate<UserWithoutPassword>({
|
||||
const users = await paginate<UserWithoutPasswordPayload>({
|
||||
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<UserWithoutPassword> {
|
||||
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<UserWithoutPassword> {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user