Add user auth with JWT, switch to bun test
- User table with company_id FK, unique email, role enum - Register/login routes with bcrypt + JWT token generation - Auth plugin with authenticate decorator and role guards - Login uses globally unique email (no company header needed) - Dev-auth plugin kept as fallback when JWT_SECRET not set - Switched from vitest to bun:test (vitest had ESM resolution issues with zod in Bun's module structure) - Upgraded to zod 4 - Added Dockerfile.dev and API service to docker-compose - 8 tests passing (health + auth)
This commit is contained in:
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../test/helpers.js'
|
||||
|
||||
describe('Auth routes', () => {
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDb(app)
|
||||
await seedTestCompany(app)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
describe('POST /v1/auth/register', () => {
|
||||
it('creates a user and returns token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'staff@musicstore.com',
|
||||
password: 'securepassword',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
role: 'staff',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(201)
|
||||
const body = response.json()
|
||||
expect(body.user.email).toBe('staff@musicstore.com')
|
||||
expect(body.user.firstName).toBe('Jane')
|
||||
expect(body.user.role).toBe('staff')
|
||||
expect(body.token).toBeDefined()
|
||||
expect(body.user.passwordHash).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicate email within same company', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'dupe@test.com',
|
||||
password: 'password123',
|
||||
firstName: 'First',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'dupe@test.com',
|
||||
password: 'password456',
|
||||
firstName: 'Second',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects invalid email', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
firstName: 'Bad',
|
||||
lastName: 'Email',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects short password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'short@test.com',
|
||||
password: '123',
|
||||
firstName: 'Short',
|
||||
lastName: 'Pass',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /v1/auth/login', () => {
|
||||
beforeEach(async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'correctpassword',
|
||||
firstName: 'Login',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns token with valid credentials', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'correctpassword',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
const body = response.json()
|
||||
expect(body.token).toBeDefined()
|
||||
expect(body.user.email).toBe('login@test.com')
|
||||
})
|
||||
|
||||
it('rejects wrong password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'wrongpassword',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects nonexistent email', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'nobody@test.com',
|
||||
password: 'whatever',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
packages/backend/src/routes/v1/auth.ts
Normal file
111
packages/backend/src/routes/v1/auth.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
|
||||
import { users } from '../../db/schema/users.js'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/auth/register', async (request, reply) => {
|
||||
const parsed = RegisterSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, role } = parsed.data
|
||||
const companyId = request.companyId
|
||||
|
||||
// Email is globally unique across all companies
|
||||
const existing = await app.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
return reply.status(409).send({
|
||||
error: { message: 'User with this email already exists', statusCode: 409 },
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||
|
||||
const [user] = await app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
companyId,
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
|
||||
const token = app.jwt.sign({
|
||||
id: user.id,
|
||||
companyId,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
return reply.status(201).send({ user, token })
|
||||
})
|
||||
|
||||
app.post('/auth/login', async (request, reply) => {
|
||||
const parsed = LoginSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data
|
||||
|
||||
// Email is globally unique — company is derived from the user record
|
||||
const [user] = await app.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return reply.status(401).send({
|
||||
error: { message: 'Invalid email or password', statusCode: 401 },
|
||||
})
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.status(401).send({
|
||||
error: { message: 'Invalid email or password', statusCode: 401 },
|
||||
})
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({
|
||||
id: user.id,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createTestApp } from '../../test/helpers.js'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user