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
+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,
}),
})
})
})