223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
|
import '../../../test/__mocks__/prisma'
|
||
|
|
|
||
|
|
import {
|
||
|
|
hashToken,
|
||
|
|
hashPassword,
|
||
|
|
verifyPassword,
|
||
|
|
validatePasswordStrength,
|
||
|
|
createSession,
|
||
|
|
getSessionToken,
|
||
|
|
getSession,
|
||
|
|
getCurrentUser,
|
||
|
|
deleteSession,
|
||
|
|
deleteAllUserSessions,
|
||
|
|
} from '@/lib/auth'
|
||
|
|
import { prisma } from '@/lib/prisma'
|
||
|
|
import { mockUser, mockSession } from '../../fixtures/users'
|
||
|
|
import { cookies } from 'next/headers'
|
||
|
|
|
||
|
|
// ─── hashToken ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('hashToken', () => {
|
||
|
|
it('returns a 64-char hex string', () => {
|
||
|
|
const hash = hashToken('sometoken')
|
||
|
|
expect(hash).toMatch(/^[a-f0-9]{64}$/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('is deterministic for the same input', () => {
|
||
|
|
expect(hashToken('abc')).toBe(hashToken('abc'))
|
||
|
|
})
|
||
|
|
|
||
|
|
it('produces different hashes for different inputs', () => {
|
||
|
|
expect(hashToken('abc')).not.toBe(hashToken('xyz'))
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── hashPassword / verifyPassword ───────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('hashPassword / verifyPassword', () => {
|
||
|
|
it('hashes a password and verifies it correctly', async () => {
|
||
|
|
const hash = await hashPassword('MyPassword1!')
|
||
|
|
expect(await verifyPassword('MyPassword1!', hash)).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns false for wrong password', async () => {
|
||
|
|
const hash = await hashPassword('MyPassword1!')
|
||
|
|
expect(await verifyPassword('WrongPassword1!', hash)).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('produces different hashes for the same password (bcrypt salt)', async () => {
|
||
|
|
const h1 = await hashPassword('SamePass1!')
|
||
|
|
const h2 = await hashPassword('SamePass1!')
|
||
|
|
expect(h1).not.toBe(h2)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── validatePasswordStrength ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('validatePasswordStrength', () => {
|
||
|
|
it('returns null for a strong password', () => {
|
||
|
|
expect(validatePasswordStrength('StrongPass1!')).toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects password shorter than 12 chars', () => {
|
||
|
|
expect(validatePasswordStrength('Short1!')).toMatch(/12 characters/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects password without uppercase', () => {
|
||
|
|
expect(validatePasswordStrength('nouppercase1!')).toMatch(/uppercase/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects password without lowercase', () => {
|
||
|
|
expect(validatePasswordStrength('NOLOWERCASE1!')).toMatch(/lowercase/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects password without number', () => {
|
||
|
|
expect(validatePasswordStrength('NoNumberHere!')).toMatch(/number/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects password without symbol', () => {
|
||
|
|
expect(validatePasswordStrength('NoSymbolHere12')).toMatch(/symbol/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('checks length first (shortest error message path)', () => {
|
||
|
|
expect(validatePasswordStrength('short')).toMatch(/12 characters/)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── createSession ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('createSession', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
vi.mocked(prisma.session.create).mockResolvedValue(mockSession)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('creates a session in DB and returns a token string', async () => {
|
||
|
|
const token = await createSession(mockUser.id)
|
||
|
|
expect(typeof token).toBe('string')
|
||
|
|
expect(token.length).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('calls prisma.session.create with userId and tokenHash', async () => {
|
||
|
|
const token = await createSession(mockUser.id)
|
||
|
|
expect(prisma.session.create).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
data: expect.objectContaining({ userId: mockUser.id }),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
|
||
|
|
const tokenHash = hashToken(token)
|
||
|
|
expect(callArg.data.tokenHash).toBe(tokenHash)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('sets expiry ~30 days from now', async () => {
|
||
|
|
await createSession(mockUser.id)
|
||
|
|
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
|
||
|
|
const expiresAt = callArg.data.expiresAt as Date
|
||
|
|
const diffDays = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||
|
|
expect(diffDays).toBeCloseTo(30, 0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── getSessionToken ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('getSessionToken', () => {
|
||
|
|
it('returns token from cookie', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
expect(await getSessionToken()).toBe('mytoken')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns null when cookie is missing', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
expect(await getSessionToken()).toBeNull()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── getSession ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('getSession', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns null when no token cookie', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
expect(await getSession()).toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns session when token is valid and not expired', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
|
||
|
|
|
||
|
|
const session = await getSession()
|
||
|
|
expect(session).not.toBeNull()
|
||
|
|
expect(session?.user.id).toBe(mockUser.id)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns null and deletes session when expired', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'expiredtoken' }), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
const expiredSession = { ...mockSession, expiresAt: new Date(Date.now() - 1000) }
|
||
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue(expiredSession as any)
|
||
|
|
vi.mocked(prisma.session.delete).mockResolvedValue(expiredSession as any)
|
||
|
|
|
||
|
|
const result = await getSession()
|
||
|
|
expect(result).toBeNull()
|
||
|
|
expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: expiredSession.id } })
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns null when session not found in DB', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'unknowntoken' }), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue(null)
|
||
|
|
|
||
|
|
expect(await getSession()).toBeNull()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── getCurrentUser ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('getCurrentUser', () => {
|
||
|
|
it('returns user from valid session', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
|
||
|
|
|
||
|
|
const user = await getCurrentUser()
|
||
|
|
expect(user?.id).toBe(mockUser.id)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns null when no session', async () => {
|
||
|
|
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
|
||
|
|
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
|
||
|
|
|
||
|
|
expect(await getCurrentUser()).toBeNull()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ─── deleteSession / deleteAllUserSessions ────────────────────────────────────
|
||
|
|
|
||
|
|
describe('deleteSession', () => {
|
||
|
|
it('calls prisma.session.deleteMany with the correct tokenHash', async () => {
|
||
|
|
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 })
|
||
|
|
await deleteSession('mytoken')
|
||
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
||
|
|
where: { tokenHash: hashToken('mytoken') },
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('deleteAllUserSessions', () => {
|
||
|
|
it('calls prisma.session.deleteMany with the userId', async () => {
|
||
|
|
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 3 })
|
||
|
|
await deleteAllUserSessions('user-123')
|
||
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-123' } })
|
||
|
|
})
|
||
|
|
})
|