00d01bdaf4
Build & Deploy / Build & Push Docker Image (push) Successful in 3m8s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('bcryptjs', () => ({
|
|
default: {
|
|
compare: vi.fn(),
|
|
hash: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() }))
|
|
vi.mock('./auth.employee.repo', () => ({
|
|
findEmployeeWithCompanyById: vi.fn(),
|
|
findEmployeeWithCompanyByEmail: vi.fn(),
|
|
findActiveEmployeeByEmail: vi.fn(),
|
|
findEmployeeByResetToken: vi.fn(),
|
|
findEmployeeById: vi.fn(),
|
|
updatePreferredLanguage: vi.fn(),
|
|
resetPassword: vi.fn(),
|
|
}))
|
|
|
|
import bcrypt from 'bcryptjs'
|
|
import { AppError } from '../../http/errors'
|
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
|
import * as repo from './auth.employee.repo'
|
|
import * as service from './auth.employee.service'
|
|
|
|
const employee = {
|
|
id: 'employee_1',
|
|
email: 'agent@example.test',
|
|
firstName: 'Aya',
|
|
lastName: 'Agent',
|
|
role: 'AGENT',
|
|
preferredLanguage: 'fr',
|
|
companyId: 'company_1',
|
|
isActive: true,
|
|
emailVerified: true,
|
|
emailVerificationToken: null,
|
|
passwordHash: 'hash_old',
|
|
company: { name: 'Atlas Cars', slug: 'atlas' },
|
|
}
|
|
|
|
describe('auth.employee.service edge behavior', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.JWT_SECRET = 'test-secret'
|
|
process.env.JWT_EXPIRY = '8h'
|
|
delete process.env.DASHBOARD_URL
|
|
delete process.env.NEXT_PUBLIC_DASHBOARD_URL
|
|
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
|
|
vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never)
|
|
})
|
|
|
|
it('rejects inactive employee sessions before presenting tenant data', async () => {
|
|
vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never)
|
|
|
|
await expect(service.getMe('employee_1')).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
error: 'unauthenticated',
|
|
})
|
|
})
|
|
|
|
it('logs in active employees with a signed token and company context', async () => {
|
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never)
|
|
|
|
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
|
|
|
expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old')
|
|
expect(result).toMatchObject({
|
|
token: expect.any(String),
|
|
employee: {
|
|
id: 'employee_1',
|
|
companyId: 'company_1',
|
|
companyName: 'Atlas Cars',
|
|
companySlug: 'atlas',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('rejects login for employees with a pending email verification token', async () => {
|
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
|
...employee,
|
|
emailVerified: null,
|
|
emailVerificationToken: 'verify-token',
|
|
} as never)
|
|
|
|
await expect(service.login({ email: 'agent@example.test', password: 'correct-password' })).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
error: 'email_not_verified',
|
|
})
|
|
})
|
|
|
|
it('logs in legacy full-signup owners that were created without a verification token', async () => {
|
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
|
...employee,
|
|
role: 'OWNER',
|
|
emailVerified: null,
|
|
emailVerificationToken: null,
|
|
} as never)
|
|
|
|
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
|
|
|
expect(result).toMatchObject({
|
|
token: expect.any(String),
|
|
employee: {
|
|
id: 'employee_1',
|
|
role: 'OWNER',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('distinguishes employees without passwords from wrong credentials', async () => {
|
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
|
|
|
await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
error: 'password_not_set',
|
|
})
|
|
expect(bcrypt.compare).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends reset links to active employees using the dashboard base path exactly once', async () => {
|
|
process.env.DASHBOARD_URL = 'https://tenant.example.test/app'
|
|
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never)
|
|
vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never)
|
|
|
|
await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({
|
|
message: 'If that email is registered, a reset link has been sent.',
|
|
})
|
|
|
|
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
to: 'agent@example.test',
|
|
subject: expect.any(String),
|
|
html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
|
text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
|
}))
|
|
})
|
|
|
|
it('does not disclose whether forgot-password emails exist', async () => {
|
|
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null)
|
|
|
|
await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({
|
|
message: 'If that email is registered, a reset link has been sent.',
|
|
})
|
|
expect(sendTransactionalEmail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('updates employee password from a stored reset token and clears reset state through the repo', async () => {
|
|
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never)
|
|
|
|
await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({
|
|
message: 'Password updated successfully. You can now sign in.',
|
|
})
|
|
|
|
expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12)
|
|
expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new')
|
|
})
|
|
|
|
it('rejects invalid reset tokens before hashing new passwords', async () => {
|
|
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
|
|
vi.mocked(repo.findEmployeeById).mockResolvedValue(null)
|
|
|
|
await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
error: 'invalid_token',
|
|
})
|
|
expect(bcrypt.hash).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('delegates language preference updates and returns a stable contract', async () => {
|
|
vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never)
|
|
|
|
await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' })
|
|
expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar')
|
|
})
|
|
})
|