Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Migration: add EMPLOYER value to the Role enum
-- This is required so users with the EMPLOYER role can be stored in the database,
-- enabling the employer B2B surface to function correctly.

ALTER TYPE "Role" ADD VALUE IF NOT EXISTS 'EMPLOYER';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ enum Role {
ADMIN
LEARNER
INSTRUCTOR
EMPLOYER
}

model ReferralCode {
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class AuthController {
return
}

const { email, password, username, role } = validation.data
const { email, password, username } = validation.data

// Check if user already exists
const existingUser = await prisma.user.findFirst({
Expand All @@ -70,13 +70,13 @@ export class AuthController {
const salt = await bcrypt.genSalt(10)
const hashedPassword = await bcrypt.hash(password, salt)

// Create user
// Create user — role is always LEARNER; callers cannot self-assign roles
const user = await prisma.user.create({
data: {
email,
username,
password: hashedPassword,
role: (role as any) || UserRole.LEARNER,
role: UserRole.LEARNER,
}
})

Expand Down
2 changes: 1 addition & 1 deletion src/controllers/employer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ function profileFromCandidate(candidate: CandidateRecord) {
}

function isEmployer(req: Request) {
return req.user?.role === 'employer'
return req.user?.role === 'EMPLOYER'
}

export const searchTalent = async (req: Request, res: Response) => {
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from 'express'

import jwt from 'jsonwebtoken'

export type UserRole = 'learner' | 'employer';
export type UserRole = 'ADMIN' | 'LEARNER' | 'INSTRUCTOR' | 'EMPLOYER';

export interface JwtPayload {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion src/routes/v1/employer.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { employerLimiter } from '../../middleware/rate-limit.middleware'

const router: Router = Router()

router.use(authenticate, authorize('employer'), employerLimiter)
router.use(authenticate, authorize('EMPLOYER'), employerLimiter)

// GET /employer/search - search talent with filters
router.get('/search', searchTalent)
Expand Down
2 changes: 0 additions & 2 deletions src/schemas/auth.schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { z } from 'zod'
import { UserRole } from '../types/user.types'

export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters long'),
username: z.string().min(3, 'Username must be at least 3 characters long'),
role: z.nativeEnum(UserRole).optional().default(UserRole.LEARNER),
})

export const loginSchema = z.object({
Expand Down
7 changes: 4 additions & 3 deletions src/types/user.types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// ── Enums ──────────────────────────────────────────────────

export enum UserRole {
ADMIN = 'admin',
LEARNER = 'learner',
INSTRUCTOR = 'instructor',
ADMIN = 'ADMIN',
LEARNER = 'LEARNER',
INSTRUCTOR = 'INSTRUCTOR',
EMPLOYER = 'EMPLOYER',
}

export enum UserStatus {
Expand Down
26 changes: 13 additions & 13 deletions tests/unit/auth.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ describe('authenticate', () => {
it('calls next() and attaches user when token is valid', () => {
const { req, res, next } = makeMocks()
req.headers = {
authorization: `Bearer ${makeToken({ id: 'user-1', email: 'a@b.com', role: 'learner' })}`,
authorization: `Bearer ${makeToken({ id: 'user-1', email: 'a@b.com', role: 'LEARNER' })}`,
}

authenticate(req as Request, res as Response, next)

expect(next).toHaveBeenCalledOnce()
expect((req as any).user).toMatchObject({ id: 'user-1', role: 'learner' })
expect((req as any).user).toMatchObject({ id: 'user-1', role: 'LEARNER' })
expect(res.status).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -76,7 +76,7 @@ describe('authenticate', () => {

it('returns 401 with "Token has expired" for an expired token', () => {
const { req, res, next } = makeMocks()
const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, -1)
const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'LEARNER' }, -1)
req.headers = { authorization: `Bearer ${token}` }

authenticate(req as Request, res as Response, next)
Expand Down Expand Up @@ -114,13 +114,13 @@ describe('optionalAuthenticate', () => {
it('attaches user and calls next() when a valid token is provided', () => {
const { req, res, next } = makeMocks()
req.headers = {
authorization: `Bearer ${makeToken({ id: 'user-2', email: 'b@c.com', role: 'employer' })}`,
authorization: `Bearer ${makeToken({ id: 'user-2', email: 'b@c.com', role: 'EMPLOYER' })}`,
}

optionalAuthenticate(req as Request, res as Response, next)

expect(next).toHaveBeenCalledOnce()
expect((req as any).user).toMatchObject({ id: 'user-2', role: 'employer' })
expect((req as any).user).toMatchObject({ id: 'user-2', role: 'EMPLOYER' })
})

it('calls next() without blocking when token is invalid', () => {
Expand All @@ -135,7 +135,7 @@ describe('optionalAuthenticate', () => {

it('calls next() without blocking when token is expired', () => {
const { req, res, next } = makeMocks()
const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, -1)
const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'LEARNER' }, -1)
req.headers = { authorization: `Bearer ${token}` }

optionalAuthenticate(req as Request, res as Response, next)
Expand All @@ -150,28 +150,28 @@ describe('optionalAuthenticate', () => {
describe('authorize', () => {
it('calls next() when user has a matching role', () => {
const { req, res, next } = makeMocks();
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' }
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'LEARNER' }

authorize('learner')(req as Request, res as Response, next)
authorize('LEARNER')(req as Request, res as Response, next)

expect(next).toHaveBeenCalledOnce()
expect(res.status).not.toHaveBeenCalled()
})

it('calls next() when user role matches one of multiple allowed roles', () => {
const { req, res, next } = makeMocks();
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' }
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'EMPLOYER' }

authorize('learner', 'employer')(req as Request, res as Response, next)
authorize('LEARNER', 'EMPLOYER')(req as Request, res as Response, next)

expect(next).toHaveBeenCalledOnce()
})

it('returns 403 when user role is not in the allowed list', () => {
const { req, res, next } = makeMocks();
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' }
(req as any).user = { id: 'u1', email: 'a@b.com', role: 'LEARNER' }

authorize('employer')(req as Request, res as Response, next)
authorize('EMPLOYER')(req as Request, res as Response, next)

expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith(
Expand All @@ -183,7 +183,7 @@ describe('authorize', () => {
it('returns 401 when req.user is not set', () => {
const { req, res, next } = makeMocks()

authorize('learner')(req as Request, res as Response, next)
authorize('LEARNER')(req as Request, res as Response, next)

expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ message: 'Authentication required' })
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/employer.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe('EmployerController', () => {
])

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' },
headers: { 'x-employer-plan': 'pro' },
query: {
skills: 'blockchain',
Expand Down Expand Up @@ -139,7 +139,7 @@ describe('EmployerController', () => {
})

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' },
params: { id: 'cand-1' },
} as unknown as Request
const res = createResponse()
Expand All @@ -158,7 +158,7 @@ describe('EmployerController', () => {
it('getCandidateProfile blocks private candidates', async () => {
process.env.PRIVATE_CANDIDATE_IDS = 'cand-private'
const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' },
params: { id: 'cand-private' },
} as unknown as Request
const res = createResponse()
Expand All @@ -171,7 +171,7 @@ describe('EmployerController', () => {

it('contactCandidate requires pro plan', async () => {
const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' },
headers: { 'x-employer-plan': 'starter' },
body: {
candidateId: 'cand-1',
Expand Down Expand Up @@ -205,7 +205,7 @@ describe('EmployerController', () => {
})

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' },
headers: { 'x-employer-plan': 'pro' },
body: {
candidateId: 'cand-1',
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/employer.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock('../../src/controllers/employer.controller', () => ({

import employerRoutes from '../../src/routes/v1/employer.routes'

function makeToken(role: 'learner' | 'employer') {
function makeToken(role: 'LEARNER' | 'EMPLOYER') {
const secret = process.env.JWT_SECRET as string

return jwt.sign({ id: 'user-1', email: 'user@example.com', role }, secret, {
Expand Down Expand Up @@ -41,7 +41,7 @@ describe('employer.routes', () => {

const response = await request(app)
.get('/employer/search')
.set('Authorization', `Bearer ${makeToken('learner')}`)
.set('Authorization', `Bearer ${makeToken('LEARNER')}`)

expect(response.status).toBe(403)
})
Expand All @@ -53,7 +53,7 @@ describe('employer.routes', () => {

const response = await request(app)
.get('/employer/search')
.set('Authorization', `Bearer ${makeToken('employer')}`)
.set('Authorization', `Bearer ${makeToken('EMPLOYER')}`)

expect(response.status).toBe(200)
expect(response.headers['x-ratelimit-limit']).toBeDefined()
Expand Down
Loading
Loading