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
205 changes: 205 additions & 0 deletions __tests__/rbac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { hasPermission, hasAnyPermission, hasAllPermissions, ROLE_PERMISSIONS, UserRole, Permission } from '@/lib/auth/constants'

describe('RBAC Constants and Helpers', () => {
describe('ROLE_PERMISSIONS', () => {
it('should have freelancer permissions defined', () => {
expect(ROLE_PERMISSIONS.freelancer).toBeDefined()
expect(ROLE_PERMISSIONS.freelancer.length).toBeGreaterThan(0)
})

it('should have client permissions defined', () => {
expect(ROLE_PERMISSIONS.client).toBeDefined()
expect(ROLE_PERMISSIONS.client.length).toBeGreaterThan(0)
})

it('should have admin permissions defined', () => {
expect(ROLE_PERMISSIONS.admin).toBeDefined()
expect(ROLE_PERMISSIONS.admin.length).toBeGreaterThan(0)
})

it('admin should have all permissions', () => {
const allPermissions: Permission[] = [
'project:create',
'project:view',
'project:update',
'project:delete',
'milestone:view',
'milestone:submit',
'milestone:approve',
'milestone:reject',
'escrow:view',
'escrow:fund',
'escrow:release',
'escrow:refund',
'contract:view',
'contract:create',
'contract:update',
'dispute:create',
'dispute:view',
'dispute:resolve',
'admin:users_manage',
'admin:contracts_freeze',
'admin:system_oversight',
'reputation:view',
'reviews:create',
'reviews:view',
]

allPermissions.forEach(permission => {
expect(ROLE_PERMISSIONS.admin).toContain(permission)
})
})
})

describe('Freelancer Permissions', () => {
it('freelancer should have milestone:submit permission', () => {
expect(hasPermission('freelancer', 'milestone:submit')).toBe(true)
})

it('freelancer should NOT have project:create permission', () => {
expect(hasPermission('freelancer', 'project:create')).toBe(false)
})

it('freelancer should NOT have milestone:approve permission', () => {
expect(hasPermission('freelancer', 'milestone:approve')).toBe(false)
})

it('freelancer should have dispute:create permission', () => {
expect(hasPermission('freelancer', 'dispute:create')).toBe(true)
})

it('freelancer should NOT have dispute:resolve permission', () => {
expect(hasPermission('freelancer', 'dispute:resolve')).toBe(false)
})
})

describe('Client Permissions', () => {
it('client should have project:create permission', () => {
expect(hasPermission('client', 'project:create')).toBe(true)
})

it('client should have milestone:approve permission', () => {
expect(hasPermission('client', 'milestone:approve')).toBe(true)
})

it('client should have milestone:reject permission', () => {
expect(hasPermission('client', 'milestone:reject')).toBe(true)
})

it('client should NOT have milestone:submit permission', () => {
expect(hasPermission('client', 'milestone:submit')).toBe(false)
})

it('client should have escrow:fund permission', () => {
expect(hasPermission('client', 'escrow:fund')).toBe(true)
})

it('client should have escrow:release permission', () => {
expect(hasPermission('client', 'escrow:release')).toBe(true)
})

it('client should NOT have dispute:resolve permission', () => {
expect(hasPermission('client', 'dispute:resolve')).toBe(false)
})
})

describe('Admin Permissions', () => {
it('admin should have dispute:resolve permission', () => {
expect(hasPermission('admin', 'dispute:resolve')).toBe(true)
})

it('admin should have admin:users_manage permission', () => {
expect(hasPermission('admin', 'admin:users_manage')).toBe(true)
})

it('admin should have admin:contracts_freeze permission', () => {
expect(hasPermission('admin', 'admin:contracts_freeze')).toBe(true)
})

it('admin should have all project permissions', () => {
expect(hasPermission('admin', 'project:create')).toBe(true)
expect(hasPermission('admin', 'project:view')).toBe(true)
expect(hasPermission('admin', 'project:update')).toBe(true)
expect(hasPermission('admin', 'project:delete')).toBe(true)
})
})

describe('hasPermission Helper', () => {
it('should return true for valid permission', () => {
expect(hasPermission('freelancer', 'milestone:submit')).toBe(true)
})

it('should return false for invalid permission', () => {
expect(hasPermission('freelancer', 'project:create')).toBe(false)
})
})

describe('hasAnyPermission Helper', () => {
it('should return true if user has any of the permissions', () => {
expect(hasAnyPermission('freelancer', ['milestone:submit', 'project:create'])).toBe(true)
})

it('should return false if user has none of the permissions', () => {
expect(hasAnyPermission('freelancer', ['project:create', 'project:delete'])).toBe(false)
})

it('should return true if user has all permissions', () => {
expect(hasAnyPermission('admin', ['dispute:resolve', 'admin:users_manage'])).toBe(true)
})
})

describe('hasAllPermissions Helper', () => {
it('should return true if user has all permissions', () => {
expect(hasAllPermissions('client', ['project:create', 'milestone:approve'])).toBe(true)
})

it('should return false if user is missing any permission', () => {
expect(hasAllPermissions('freelancer', ['milestone:submit', 'project:create'])).toBe(false)
})

it('should return true for admin with any permissions', () => {
expect(hasAllPermissions('admin', ['dispute:resolve', 'admin:users_manage'])).toBe(true)
})
})

describe('Permission Scope by Role', () => {
it('freelancer should only have view permissions for projects', () => {
expect(hasPermission('freelancer', 'project:view')).toBe(true)
expect(hasPermission('freelancer', 'project:create')).toBe(false)
expect(hasPermission('freelancer', 'project:update')).toBe(false)
expect(hasPermission('freelancer', 'project:delete')).toBe(false)
})

it('client should have full project management permissions', () => {
expect(hasPermission('client', 'project:view')).toBe(true)
expect(hasPermission('client', 'project:create')).toBe(true)
expect(hasPermission('client', 'project:update')).toBe(true)
expect(hasPermission('client', 'project:delete')).toBe(true)
})

it('freelancer should have submit but not approve milestones', () => {
expect(hasPermission('freelancer', 'milestone:submit')).toBe(true)
expect(hasPermission('freelancer', 'milestone:approve')).toBe(false)
expect(hasPermission('freelancer', 'milestone:reject')).toBe(false)
})

it('client should have approve and reject but not submit milestones', () => {
expect(hasPermission('client', 'milestone:submit')).toBe(false)
expect(hasPermission('client', 'milestone:approve')).toBe(true)
expect(hasPermission('client', 'milestone:reject')).toBe(true)
})

it('only admin should have admin-specific permissions', () => {
expect(hasPermission('admin', 'admin:users_manage')).toBe(true)
expect(hasPermission('admin', 'admin:contracts_freeze')).toBe(true)
expect(hasPermission('admin', 'admin:system_oversight')).toBe(true)

expect(hasPermission('freelancer', 'admin:users_manage')).toBe(false)
expect(hasPermission('client', 'admin:users_manage')).toBe(false)

expect(hasPermission('freelancer', 'admin:contracts_freeze')).toBe(false)
expect(hasPermission('client', 'admin:contracts_freeze')).toBe(false)
})
})
})
6 changes: 2 additions & 4 deletions app/api/disputes/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@ export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { sql } from '@/lib/db'
import { withAuth } from '@/lib/auth/middleware'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'

type RouteContext = { params: Promise<{ id: string }> }

export const POST = withAuth(async (request: NextRequest, auth, context: RouteContext) => {
export const POST = withRbac('dispute:resolve', async (request: NextRequest, auth: RbacContext, context: RouteContext) => {
const { id } = await context.params
try {
const { resolution, newState } = await request.json()
if (!resolution) return NextResponse.json({ error: 'Missing required field: resolution', code: 'MISSING_FIELDS' }, { status: 400 })
const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}`
if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 })
const [dispute] = await sql`SELECT d.*, j.escrow_contract_id FROM disputes d JOIN jobs j ON d.job_id = j.id WHERE d.id = ${id}`
if (!dispute) return NextResponse.json({ error: 'Dispute not found', code: 'DISPUTE_NOT_FOUND' }, { status: 404 })
const nextState = newState || 'resolved'
Expand Down
14 changes: 5 additions & 9 deletions app/api/disputes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { sql } from '@/lib/db'
import { withAuth } from '@/lib/auth/middleware'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'

export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withRbac('dispute:create', async (request: NextRequest, auth: RbacContext) => {
try {
const body = await request.json()
const { jobId, reason } = body
Expand All @@ -13,24 +13,20 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
}
const [job] = await sql`SELECT id, client_id, freelancer_id FROM jobs WHERE id = ${jobId}`
if (!job) return NextResponse.json({ error: 'Job not found', code: 'JOB_NOT_FOUND' }, { status: 404 })
const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}`
if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 })
const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${user.id}, ${reason}) RETURNING *`
const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${auth.userId}, ${reason}) RETURNING *`
await sql`UPDATE jobs SET status = 'disputed', updated_at = CURRENT_TIMESTAMP WHERE id = ${jobId}`
return NextResponse.json(dispute, { status: 201 })
} catch {
return NextResponse.json({ error: 'Failed to raise dispute', code: 'DISPUTE_CREATION_FAILED' }, { status: 500 })
}
})

export const GET = withAuth(async (_request: NextRequest, auth) => {
export const GET = withRbac('dispute:view', async (_request: NextRequest, auth: RbacContext) => {
try {
const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}`
if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 })
const disputes = await sql`
SELECT d.*, j.title as job_title, u.username as raised_by_username
FROM disputes d JOIN jobs j ON d.job_id = j.id JOIN users u ON d.raised_by = u.id
WHERE j.client_id = ${user.id} OR j.freelancer_id = ${user.id}
WHERE j.client_id = ${auth.userId} OR j.freelancer_id = ${auth.userId}
ORDER BY d.created_at DESC
`
return NextResponse.json(disputes, { status: 200 })
Expand Down
4 changes: 2 additions & 2 deletions app/api/escrow/fund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
*/

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'

export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
try {
body = await request.json()
Expand Down
4 changes: 2 additions & 2 deletions app/api/escrow/refund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
*/

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'

export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
try {
body = await request.json()
Expand Down
4 changes: 2 additions & 2 deletions app/api/escrow/release/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
*/

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'

export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withRbac('escrow:release', async (request: NextRequest, auth: RbacContext) => {
let body: Record<string, unknown>
try {
body = await request.json()
Expand Down
9 changes: 3 additions & 6 deletions app/api/milestones/[id]/approve/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { sql } from '@/lib/db'

// Only the contract client can approve (or reject) a submitted milestone
export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withAnyRbac(['milestone:approve', 'milestone:reject'], async (request: NextRequest, auth: RbacContext) => {
const id = request.nextUrl.pathname.split('/').at(-2)

try {
Expand All @@ -26,9 +26,6 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
)
}

const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1`
if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 })

// Fetch milestone with contract info to verify client role
const [milestone] = await sql`
SELECT m.*, c.client_id
Expand All @@ -39,7 +36,7 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
`
if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 })

if (!milestone.contract_id || milestone.client_id !== user.id) {
if (!milestone.contract_id || milestone.client_id !== auth.userId) {
return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 })
}

Expand Down
9 changes: 3 additions & 6 deletions app/api/milestones/[id]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { sql } from '@/lib/db'

// Only the contract freelancer can submit a milestone (status must be pending or in_progress)
export const POST = withAuth(async (request: NextRequest, auth) => {
export const POST = withRbac('milestone:submit', async (request: NextRequest, auth: RbacContext) => {
const id = request.nextUrl.pathname.split('/').at(-2)

try {
const body = await request.json().catch(() => ({}))
const { deliverables } = body

const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1`
if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 })

// Fetch milestone with contract info to verify freelancer role
const [milestone] = await sql`
SELECT m.*, c.freelancer_id
Expand All @@ -26,7 +23,7 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 })

// Must have a contract and caller must be the freelancer
if (!milestone.contract_id || milestone.freelancer_id !== user.id) {
if (!milestone.contract_id || milestone.freelancer_id !== auth.userId) {
return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 })
}

Expand Down
Loading
Loading