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 { 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({
|
||||||
const admin = getBootstrapAdminInput()
|
|
||||||
|
|
||||||
await client.user.upsert({
|
|
||||||
where: {
|
where: {
|
||||||
email: admin.email,
|
id: existingUser.id,
|
||||||
},
|
},
|
||||||
update: {
|
data: {
|
||||||
role: "ADMIN",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: admin.name,
|
name: admin.name,
|
||||||
email: admin.email,
|
email: admin.email,
|
||||||
|
emailNormalized: email,
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
password: await getPasswordHash(admin.password),
|
status: UserStatus.ACTIVE,
|
||||||
isActive: true,
|
...(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() {
|
async function main() {
|
||||||
|
|||||||
+9
-6
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase()
|
||||||
|
}
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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