From ca3d3e4bfa996cc1e53dde6ff39cb95d57f5b6bf Mon Sep 17 00:00:00 2001 From: Magrexy Date: Wed, 19 Aug 2026 12:16:06 +0000 Subject: [PATCH] fix(auth): unify role model to single EMPLOYER-inclusive enum - Add EMPLOYER to prisma/schema.prisma Role enum with migration (20260819000001_add_employer_role/migration.sql) - Align UserRole enum values in src/types/user.types.ts to uppercase (ADMIN, LEARNER, INSTRUCTOR, EMPLOYER) matching the Prisma enum - Remove role field from registerSchema so callers cannot self-assign roles; register() always persists Role.LEARNER - Update src/middleware/auth.middleware.ts UserRole type to include all four canonical roles (uppercase) so authorize() comparisons are consistent - Update isEmployer() check in employer.controller.ts to EMPLOYER - Update employer.routes.ts to authorize('EMPLOYER') - Update test fixtures in auth.middleware, employer.controller, and employer.routes tests to use uppercase role values - Add tests/unit/role-model.test.ts covering: UserRole enum shape, authorize EMPLOYER/LEARNER, register always writes LEARNER regardless of client-supplied role field Closes #4 --- .../migration.sql | 5 + prisma/schema.prisma | 1 + src/controllers/auth.controller.ts | 6 +- src/controllers/employer.controller.ts | 2 +- src/middleware/auth.middleware.ts | 2 +- src/routes/v1/employer.routes.ts | 2 +- src/schemas/auth.schema.ts | 2 - src/types/user.types.ts | 7 +- tests/unit/auth.middleware.test.ts | 26 +- tests/unit/employer.controller.test.ts | 10 +- tests/unit/employer.routes.test.ts | 6 +- tests/unit/role-model.test.ts | 236 ++++++++++++++++++ 12 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260819000001_add_employer_role/migration.sql create mode 100644 tests/unit/role-model.test.ts diff --git a/prisma/migrations/20260819000001_add_employer_role/migration.sql b/prisma/migrations/20260819000001_add_employer_role/migration.sql new file mode 100644 index 00000000..010e808c --- /dev/null +++ b/prisma/migrations/20260819000001_add_employer_role/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2cb42cf8..b4b50cca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,7 @@ enum Role { ADMIN LEARNER INSTRUCTOR + EMPLOYER } model ReferralCode { diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 31069a32..c4b23b38 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -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({ @@ -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, } }) diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index 59dc542e..094ab34b 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -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) => { diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 59203183..ff6e0e85 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -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; diff --git a/src/routes/v1/employer.routes.ts b/src/routes/v1/employer.routes.ts index ed5eb11e..09e9e00b 100644 --- a/src/routes/v1/employer.routes.ts +++ b/src/routes/v1/employer.routes.ts @@ -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) diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 7b552a48..eb1cf415 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -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({ diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 2ba9321b..4df6bc62 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -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 { diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts index e0da93bd..5d5bcebc 100644 --- a/tests/unit/auth.middleware.test.ts +++ b/tests/unit/auth.middleware.test.ts @@ -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() }) @@ -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) @@ -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', () => { @@ -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) @@ -150,9 +150,9 @@ 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() @@ -160,18 +160,18 @@ describe('authorize', () => { 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( @@ -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' }) diff --git a/tests/unit/employer.controller.test.ts b/tests/unit/employer.controller.test.ts index 50a8de27..0b7227a0 100644 --- a/tests/unit/employer.controller.test.ts +++ b/tests/unit/employer.controller.test.ts @@ -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', @@ -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() @@ -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() @@ -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', @@ -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', diff --git a/tests/unit/employer.routes.test.ts b/tests/unit/employer.routes.test.ts index 414d5921..b921609c 100644 --- a/tests/unit/employer.routes.test.ts +++ b/tests/unit/employer.routes.test.ts @@ -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, { @@ -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) }) @@ -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() diff --git a/tests/unit/role-model.test.ts b/tests/unit/role-model.test.ts new file mode 100644 index 00000000..be984516 --- /dev/null +++ b/tests/unit/role-model.test.ts @@ -0,0 +1,236 @@ +/** + * tests/unit/role-model.test.ts + * + * Acceptance criteria for issue #4: + * - register no longer reads a role from the request body + * - created user's role is always LEARNER + * - a user with EMPLOYER role can pass authorize('EMPLOYER') + * - a user with LEARNER role is rejected 403 from employer routes + * - UserRole enum values align with the Prisma Role enum (uppercase) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { AuthController } from '../../src/controllers/auth.controller' +import { UserRole } from '../../src/types/user.types' + +// ── Mock Prisma ─────────────────────────────────────────────────────────────── + +vi.mock('../../src/config/database', () => ({ + default: { + user: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + }, + prisma: { + user: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + }, +})) + +// ── Mock JWT (stable secret) ────────────────────────────────────────────────── + +vi.stubEnv('JWT_SECRET', 'test-secret-key') + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeResMock(): Partial { + const res: Partial = {} + res.status = vi.fn().mockReturnValue(res) + res.json = vi.fn().mockReturnValue(res) + + return res +} + +// ── UserRole enum alignment ─────────────────────────────────────────────────── + +describe('UserRole enum', () => { + it('has EMPLOYER member with uppercase value', () => { + expect(UserRole.EMPLOYER).toBe('EMPLOYER') + }) + + it('has LEARNER member with uppercase value matching Prisma Role enum', () => { + expect(UserRole.LEARNER).toBe('LEARNER') + }) + + it('has ADMIN member with uppercase value', () => { + expect(UserRole.ADMIN).toBe('ADMIN') + }) + + it('has INSTRUCTOR member with uppercase value', () => { + expect(UserRole.INSTRUCTOR).toBe('INSTRUCTOR') + }) +}) + +// ── authorize ───────────────────────────────────────────────────────────────── + +describe('authorize – EMPLOYER access', () => { + // Import dynamically so the env stub is in effect + const getAuthorize = async () => { + const { authorize } = await import('../../src/middleware/auth.middleware') + + return authorize + } + + it('allows a user with EMPLOYER role to pass authorize("EMPLOYER")', async () => { + const authorize = await getAuthorize() + const req = { user: { id: 'u1', email: 'e@e.com', role: 'EMPLOYER' } } as Partial + const res = makeResMock() + const next = vi.fn() + + authorize('EMPLOYER')(req as Request, res as Response, next) + + expect(next).toHaveBeenCalledOnce() + expect(res.status).not.toHaveBeenCalled() + }) + + it('rejects a LEARNER from the EMPLOYER-only route with 403', async () => { + const authorize = await getAuthorize() + const req = { user: { id: 'u1', email: 'l@l.com', role: 'LEARNER' } } as Partial + const res = makeResMock() + const next = vi.fn() + + authorize('EMPLOYER')(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + + it('rejects an INSTRUCTOR from the EMPLOYER-only route with 403', async () => { + const authorize = await getAuthorize() + const req = { user: { id: 'u1', email: 'i@i.com', role: 'INSTRUCTOR' } } as Partial + const res = makeResMock() + const next = vi.fn() + + authorize('EMPLOYER')(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) +}) + +// ── register – always creates LEARNER ──────────────────────────────────────── + +describe('AuthController.register – role enforcement', () => { + let authController: AuthController + + beforeEach(async () => { + vi.resetModules() + const mod = await import('../../src/config/database') + const prisma = mod.default + + // Happy-path: user does not exist yet, creation succeeds + ;(prisma.user.findFirst as ReturnType).mockResolvedValue(null) + ;(prisma.user.create as ReturnType).mockImplementation(({ data }: any) => + Promise.resolve({ + id: 'new-user-id', + email: data.email, + username: data.username, + role: data.role, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + + authController = new AuthController() + }) + + it('creates the user with role LEARNER when no role is supplied', async () => { + const prisma = (await import('../../src/config/database')).default + const req = { + body: { + email: 'alice@example.com', + password: 'P@ssword123', + username: 'alice', + // no role field + }, + } as Partial + const res = makeResMock() + + await authController.register(req as Request, res as Response) + + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'LEARNER' }), + }), + ) + expect(res.status).toHaveBeenCalledWith(201) + }) + + it('creates the user with role LEARNER even when client sends role: EMPLOYER', async () => { + const prisma = (await import('../../src/config/database')).default + const req = { + body: { + email: 'bob@example.com', + password: 'P@ssword123', + username: 'bob', + role: 'EMPLOYER', // adversarial input — should be ignored + }, + } as Partial + const res = makeResMock() + + await authController.register(req as Request, res as Response) + + // The prisma.user.create call must always write LEARNER, never EMPLOYER + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'LEARNER' }), + }), + ) + }) + + it('returns a token whose role claim is LEARNER (uppercase, Prisma-aligned)', async () => { + const req = { + body: { + email: 'carol@example.com', + password: 'P@ssword123', + username: 'carol', + }, + } as Partial + const res = makeResMock() + + await authController.register(req as Request, res as Response) + + const jsonArg = (res.json as ReturnType).mock.calls[0][0] + expect(jsonArg).toMatchObject({ user: { role: 'LEARNER' } }) + }) + + it('returns 409 when email or username already exists', async () => { + const prisma = (await import('../../src/config/database')).default + ;(prisma.user.findFirst as ReturnType).mockResolvedValue({ + id: 'existing', + email: 'alice@example.com', + }) + + const req = { + body: { + email: 'alice@example.com', + password: 'P@ssword123', + username: 'alice', + }, + } as Partial + const res = makeResMock() + + await authController.register(req as Request, res as Response) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('returns 400 for invalid request body (missing required fields)', async () => { + const req = { + body: { email: 'not-an-email' }, + } as Partial + const res = makeResMock() + + await authController.register(req as Request, res as Response) + + expect(res.status).toHaveBeenCalledWith(400) + }) +})