diff --git a/docs/API.md b/docs/API.md index e914f71..1bff7ce 100644 --- a/docs/API.md +++ b/docs/API.md @@ -405,7 +405,7 @@ GET /v1/credentials/verify/:onChainId GET /v1/employer/search?skills=finance,defi&location=kenya ``` -**Authentication:** Requires employer API key +**Authentication:** Requires an authenticated employer JWT. The per-plan page limit (`starter: 10`, `pro: 50`, `enterprise: 100`) is derived from the employer's persisted plan, not from a request header. **Response:** diff --git a/integrations/unit/employer.controller.test.ts b/integrations/unit/employer.controller.test.ts index 266c184..31f74dc 100644 --- a/integrations/unit/employer.controller.test.ts +++ b/integrations/unit/employer.controller.test.ts @@ -27,6 +27,40 @@ function createResponse() { return response as Response } +// `getEmployerPlan` resolves the plan by querying the authenticated employer's +// persisted row (id 'emp-1'). Every other id resolves through the per-test candidate mock. +function mockEmployerPlan(plan: string | null) { + ;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => { + if (where.id === 'emp-1') { + return plan ? { id: 'emp-1', plan } : null + } + + return null + }) +} + +const candidateFixture = { + id: 'cand-1', + email: 'alice.learner+seed@orivex.dev', + username: 'Alice Learner', + createdAt: new Date('2026-01-01T00:00:00Z'), + completions: [ + { + score: 90, + completedAt: new Date('2026-02-01T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], + credentials: [ + { + id: 'cred-1', + onChainId: 'chain-cred-1', + issuedAt: new Date('2026-02-02T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], +} + describe('EmployerController', () => { beforeEach(() => { vi.clearAllMocks() @@ -34,54 +68,20 @@ describe('EmployerController', () => { }) it('searchTalent returns candidates matching filters and excludes private profiles', async () => { + mockEmployerPlan('pro') process.env.PRIVATE_CANDIDATE_IDS = 'cand-2' ;(prisma.user.findMany as any).mockResolvedValue([ - { - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - name: 'Alice Learner', - createdAt: new Date('2026-01-01T00:00:00Z'), - completions: [ - { - score: 90, - completedAt: new Date('2026-02-01T00:00:00Z'), - module: { - id: 'm1', - title: 'Stellar Fundamentals', - category: 'blockchain', - difficulty: 'beginner', - }, - }, - ], - credentials: [ - { - id: 'cred-1', - onChainId: 'chain-cred-1', - issuedAt: new Date('2026-02-02T00:00:00Z'), - module: { - id: 'm1', - title: 'Stellar Fundamentals', - category: 'blockchain', - difficulty: 'beginner', - }, - }, - ], - }, + candidateFixture, { id: 'cand-2', email: 'bob.learner+seed@orivex.dev', - name: 'Bob Learner', + username: 'Bob Learner', createdAt: new Date('2026-01-01T00:00:00Z'), completions: [ { score: 88, completedAt: new Date('2026-02-01T00:00:00Z'), - module: { - id: 'm2', - title: 'Wallet Security & Key Management', - category: 'security', - difficulty: 'intermediate', - }, + module: { id: 'm2', title: 'Wallet Security & Key Management', category: 'security', difficulty: 'intermediate' }, }, ], credentials: [], @@ -90,12 +90,7 @@ describe('EmployerController', () => { const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' }, - headers: { 'x-employer-plan': 'pro' }, - query: { - skills: 'blockchain', - location: 'lagos', - credentials: 'verified', - }, + query: { skills: 'blockchain', location: 'lagos', credentials: 'verified' }, } as unknown as Request const res = createResponse() @@ -111,32 +106,57 @@ describe('EmployerController', () => { verifiedCredentialCount: 1, }), ], + plan: 'pro', + }), + ) + }) + + it('searchTalent caps the page limit from the persisted enterprise plan', async () => { + mockEmployerPlan('enterprise') + ;(prisma.user.findMany as any).mockResolvedValue([candidateFixture]) + + const req = { + user: { id: 'emp-1', role: 'employer' }, + query: { limit: '100' }, + } as unknown as Request + const res = createResponse() + + await searchTalent(req, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + pagination: expect.objectContaining({ limit: 100 }), + }), + ) + }) + + it('searchTalent ignores a spoofed enterprise header when the persisted plan is starter', async () => { + mockEmployerPlan('starter') + ;(prisma.user.findMany as any).mockResolvedValue([candidateFixture]) + + const req = { + user: { id: 'emp-1', role: 'employer' }, + headers: { 'x-employer-plan': 'enterprise' }, + query: { limit: '100' }, + } as unknown as Request + const res = createResponse() + + await searchTalent(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Current plan allows up to 10 results per page', + currentPlan: 'starter', + requestedLimit: 100, + maxLimit: 10, }), ) }) it('getCandidateProfile returns profile with verified credentials', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - name: 'Alice Learner', - createdAt: new Date('2026-01-01T00:00:00Z'), - completions: [ - { - score: 91, - completedAt: new Date('2026-02-01T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, - }, - ], - credentials: [ - { - id: 'cred-1', - onChainId: 'onchain-abc', - issuedAt: new Date('2026-02-03T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, - }, - ], - }) + ;(prisma.user.findUnique as any).mockResolvedValue(candidateFixture) const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' }, @@ -169,10 +189,11 @@ describe('EmployerController', () => { expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' }) }) - it('contactCandidate requires pro plan', async () => { + it('contactCandidate returns 402 for a starter employer from persistence', async () => { + mockEmployerPlan('starter') + const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' }, - headers: { 'x-employer-plan': 'starter' }, body: { candidateId: 'cand-1', subject: 'Role opportunity', @@ -188,15 +209,43 @@ describe('EmployerController', () => { expect.objectContaining({ message: 'Employer plan upgrade required', requiredPlan: 'pro', + currentPlan: 'starter', }), ) }) - it('contactCandidate records outreach attempts', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - name: 'Alice Learner', + it('contactCandidate ignores a spoofed pro header when the persisted plan is starter', async () => { + mockEmployerPlan('starter') + + const req = { + user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' }, + headers: { 'x-employer-plan': 'pro' }, + body: { + candidateId: 'cand-1', + subject: 'Role opportunity', + message: 'We would like to invite you to interview for a backend role.', + }, + } as unknown as Request + const res = createResponse() + + await contactCandidate(req, res) + + expect(res.status).toHaveBeenCalledWith(402) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Employer plan upgrade required' }), + ) + }) + + it('contactCandidate records outreach attempts for a pro employer', async () => { + ;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => { + if (where.id === 'emp-1') { + return { id: 'emp-1', plan: 'pro' } + } + if (where.id === 'cand-1') { + return { id: 'cand-1', email: 'alice.learner+seed@orivex.dev', username: 'Alice Learner' } + } + + return null }) ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) ;(prisma.webhookDelivery.create as any).mockResolvedValue({ @@ -206,7 +255,6 @@ describe('EmployerController', () => { const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' }, - headers: { 'x-employer-plan': 'pro' }, body: { candidateId: 'cand-1', subject: 'Role opportunity', @@ -223,6 +271,7 @@ describe('EmployerController', () => { expect.objectContaining({ data: expect.objectContaining({ eventType: 'employer.contact_attempt', + payload: expect.stringContaining('"employerPlan":"pro"'), }), }), ) diff --git a/prisma/migrations/20260819140000_add_employer_plan/migration.sql b/prisma/migrations/20260819140000_add_employer_plan/migration.sql new file mode 100644 index 0000000..d202d83 --- /dev/null +++ b/prisma/migrations/20260819140000_add_employer_plan/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "plan" TEXT NOT NULL DEFAULT 'starter'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 96e64e2..48810be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,6 +12,7 @@ model User { username String @unique password String role Role @default(LEARNER) + plan String @default("starter") // starter, pro, enterprise walletAddress String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index 30059ae..17c456b 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -39,10 +39,12 @@ const PLAN_MAX_SEARCH_LIMIT: Record = { enterprise: 100, } -function getEmployerPlan(req: Request) { - const fromHeader = req.headers['x-employer-plan'] - const planValue = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader - const normalized = String(planValue ?? 'starter').toLowerCase() +async function getEmployerPlan(req: Request) { + const user = await prisma.user.findUnique({ + where: { id: req.user?.id }, + select: { plan: true }, + }) + const normalized = String(user?.plan ?? 'starter').toLowerCase() return PLAN_RANK[normalized] ? normalized : 'starter' } @@ -159,7 +161,7 @@ export const searchTalent = async (req: Request, res: Response) => { } const { page, limit, skills, location, credentials, search } = parsed.data - const employerPlan = getEmployerPlan(req) + const employerPlan = await getEmployerPlan(req) const maxLimit = PLAN_MAX_SEARCH_LIMIT[employerPlan] ?? PLAN_MAX_SEARCH_LIMIT.starter if (limit > maxLimit) { return res.status(400).json({ @@ -330,7 +332,7 @@ export const contactCandidate = async (req: Request, res: Response) => { return res.status(403).json({ message: 'Employer account required' }) } - const employerPlan = getEmployerPlan(req) + const employerPlan = await getEmployerPlan(req) if (PLAN_RANK[employerPlan] < PLAN_RANK.pro) { return res.status(402).json({ message: 'Employer plan upgrade required', diff --git a/tests/unit/employer.controller.test.ts b/tests/unit/employer.controller.test.ts index 0b7227a..73806af 100644 --- a/tests/unit/employer.controller.test.ts +++ b/tests/unit/employer.controller.test.ts @@ -27,6 +27,40 @@ function createResponse() { return response as Response } +// `getEmployerPlan` resolves the plan by querying the authenticated employer's +// persisted row (id 'emp-1'). Every other id resolves through the per-test candidate mock. +function mockEmployerPlan(plan: string | null) { + ;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => { + if (where.id === 'emp-1') { + return plan ? { id: 'emp-1', plan } : null + } + + return null + }) +} + +const candidateFixture = { + id: 'cand-1', + email: 'alice.learner+seed@orivex.dev', + username: 'Alice Learner', + createdAt: new Date('2026-01-01T00:00:00Z'), + completions: [ + { + score: 90, + completedAt: new Date('2026-02-01T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], + credentials: [ + { + id: 'cred-1', + onChainId: 'chain-cred-1', + issuedAt: new Date('2026-02-02T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], +} + describe('EmployerController', () => { beforeEach(() => { vi.clearAllMocks() @@ -34,39 +68,10 @@ describe('EmployerController', () => { }) it('searchTalent returns candidates matching filters and excludes private profiles', async () => { + mockEmployerPlan('pro') process.env.PRIVATE_CANDIDATE_IDS = 'cand-2' ;(prisma.user.findMany as any).mockResolvedValue([ - { - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - username: 'Alice Learner', - createdAt: new Date('2026-01-01T00:00:00Z'), - completions: [ - { - score: 90, - completedAt: new Date('2026-02-01T00:00:00Z'), - module: { - id: 'm1', - title: 'Stellar Fundamentals', - category: 'blockchain', - difficulty: 'beginner', - }, - }, - ], - credentials: [ - { - id: 'cred-1', - onChainId: 'chain-cred-1', - issuedAt: new Date('2026-02-02T00:00:00Z'), - module: { - id: 'm1', - title: 'Stellar Fundamentals', - category: 'blockchain', - difficulty: 'beginner', - }, - }, - ], - }, + candidateFixture, { id: 'cand-2', email: 'bob.learner+seed@orivex.dev', @@ -76,12 +81,7 @@ describe('EmployerController', () => { { score: 88, completedAt: new Date('2026-02-01T00:00:00Z'), - module: { - id: 'm2', - title: 'Wallet Security & Key Management', - category: 'security', - difficulty: 'intermediate', - }, + module: { id: 'm2', title: 'Wallet Security & Key Management', category: 'security', difficulty: 'intermediate' }, }, ], credentials: [], @@ -111,32 +111,57 @@ describe('EmployerController', () => { verifiedCredentialCount: 1, }), ], + plan: 'pro', + }), + ) + }) + + it('searchTalent caps the page limit from the persisted enterprise plan', async () => { + mockEmployerPlan('enterprise') + ;(prisma.user.findMany as any).mockResolvedValue([candidateFixture]) + + const req = { + user: { id: 'emp-1', role: 'EMPLOYER' }, + query: { limit: '100' }, + } as unknown as Request + const res = createResponse() + + await searchTalent(req, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + pagination: expect.objectContaining({ limit: 100 }), + }), + ) + }) + + it('searchTalent ignores a spoofed enterprise header when the persisted plan is starter', async () => { + mockEmployerPlan('starter') + ;(prisma.user.findMany as any).mockResolvedValue([candidateFixture]) + + const req = { + user: { id: 'emp-1', role: 'EMPLOYER' }, + headers: { 'x-employer-plan': 'enterprise' }, + query: { limit: '100' }, + } as unknown as Request + const res = createResponse() + + await searchTalent(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Current plan allows up to 10 results per page', + currentPlan: 'starter', + requestedLimit: 100, + maxLimit: 10, }), ) }) it('getCandidateProfile returns profile with verified credentials', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - username: 'Alice Learner', - createdAt: new Date('2026-01-01T00:00:00Z'), - completions: [ - { - score: 91, - completedAt: new Date('2026-02-01T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, - }, - ], - credentials: [ - { - id: 'cred-1', - onChainId: 'onchain-abc', - issuedAt: new Date('2026-02-03T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, - }, - ], - }) + ;(prisma.user.findUnique as any).mockResolvedValue(candidateFixture) const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' }, @@ -169,7 +194,9 @@ describe('EmployerController', () => { expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' }) }) - it('contactCandidate requires pro plan', async () => { + it('contactCandidate returns 402 for a starter employer from persistence', async () => { + mockEmployerPlan('starter') + const req = { user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' }, headers: { 'x-employer-plan': 'starter' }, @@ -188,15 +215,43 @@ describe('EmployerController', () => { expect.objectContaining({ message: 'Employer plan upgrade required', requiredPlan: 'pro', + currentPlan: 'starter', }), ) }) - it('contactCandidate records outreach attempts', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'cand-1', - email: 'alice.learner+seed@orivex.dev', - username: 'Alice Learner', + it('contactCandidate ignores a spoofed pro header when the persisted plan is starter', async () => { + mockEmployerPlan('starter') + + const req = { + user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'EMPLOYER' }, + headers: { 'x-employer-plan': 'pro' }, + body: { + candidateId: 'cand-1', + subject: 'Role opportunity', + message: 'We would like to invite you to interview for a backend role.', + }, + } as unknown as Request + const res = createResponse() + + await contactCandidate(req, res) + + expect(res.status).toHaveBeenCalledWith(402) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Employer plan upgrade required' }), + ) + }) + + it('contactCandidate records outreach attempts for a pro employer', async () => { + ;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => { + if (where.id === 'emp-1') { + return { id: 'emp-1', plan: 'pro' } + } + if (where.id === 'cand-1') { + return { id: 'cand-1', email: 'alice.learner+seed@orivex.dev', username: 'Alice Learner' } + } + + return null }) ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) ;(prisma.webhookDelivery.create as any).mockResolvedValue({ @@ -223,6 +278,7 @@ describe('EmployerController', () => { expect.objectContaining({ data: expect.objectContaining({ eventType: 'employer.contact_attempt', + payload: expect.stringContaining('"employerPlan":"pro"'), }), }), ) diff --git a/tests/unit/module.controller.test.ts b/tests/unit/module.controller.test.ts index dca3086..8734a07 100644 --- a/tests/unit/module.controller.test.ts +++ b/tests/unit/module.controller.test.ts @@ -16,6 +16,9 @@ vi.mock('../../src/config/database', () => ({ module: { findUnique: vi.fn(), }, + quizQuestion: { + findMany: vi.fn(), + }, completion: { findUnique: vi.fn(), update: vi.fn(), @@ -65,6 +68,9 @@ describe('ModuleController.completeModule', () => { const completedAt = new Date('2026-01-01T00:00:00Z') ;(prisma.completion.update as any).mockResolvedValue({ completedAt }) ;(prisma.transaction.create as any).mockResolvedValue({ id: 'txn-1' }) + ;(prisma.quizQuestion.findMany as any).mockResolvedValue([ + { id: 'q1', moduleId: 'mod-1', prompt: 'Q1', options: '[]', answerKey: 'a', position: 0 }, + ]) const req = { user: { id: 'user-1' },