Skip to content
Open
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
120 changes: 120 additions & 0 deletions __tests__/admin/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

vi.mock('@/lib/db', () => ({ sql: vi.fn() }))
vi.mock('@/lib/auth/session', () => ({
readAccessToken: vi.fn(),
verifyAccessToken: vi.fn(),
}))
vi.mock('@/lib/admin/audit', () => ({ writeAuditLog: vi.fn() }))

import { sql } from '@/lib/db'
import { readAccessToken, verifyAccessToken } from '@/lib/auth/session'
import {
POST as freezeContract,
DELETE as unfreezeContract,
} from '@/app/api/admin/contracts/[id]/freeze/route'

const mockSql = vi.mocked(sql)
const mockReadToken = vi.mocked(readAccessToken)
const mockVerifyToken = vi.mocked(verifyAccessToken)

function asAdmin() {
mockReadToken.mockReturnValue('token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti' })
mockSql.mockResolvedValueOnce([{ id: 1, user_type: 'admin', is_banned: false }] as never)
}

function makeCtx(id: string) {
return { params: Promise.resolve({ id }) }
}

beforeEach(() => vi.clearAllMocks())

describe('POST /api/admin/contracts/[id]/freeze', () => {
it('freezes a contract', async () => {
asAdmin()
mockSql
.mockResolvedValueOnce([{ id: 10, is_frozen: false, status: 'in_progress' }] as never)
.mockResolvedValueOnce([{ id: 10, is_frozen: true, frozen_at: new Date(), freeze_reason: 'Suspicious' }] as never)

const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', {
method: 'POST',
body: JSON.stringify({ reason: 'Suspicious activity' }),
headers: { 'content-type': 'application/json' },
})
const res = await freezeContract(req, makeCtx('10'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.contract.is_frozen).toBe(true)
})

it('returns 409 if already frozen', async () => {
asAdmin()
mockSql.mockResolvedValueOnce([{ id: 10, is_frozen: true, status: 'in_progress' }] as never)

const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', {
method: 'POST',
body: JSON.stringify({ reason: 'Double freeze' }),
headers: { 'content-type': 'application/json' },
})
const res = await freezeContract(req, makeCtx('10'))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.code).toBe('ALREADY_FROZEN')
})

it('returns 404 for unknown contract', async () => {
asAdmin()
mockSql.mockResolvedValueOnce([] as never)

const req = new NextRequest('http://localhost/api/admin/contracts/999/freeze', {
method: 'POST',
body: JSON.stringify({ reason: 'Test' }),
headers: { 'content-type': 'application/json' },
})
const res = await freezeContract(req, makeCtx('999'))
expect(res.status).toBe(404)
})

it('returns 422 when reason is missing', async () => {
asAdmin()
const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', {
method: 'POST',
body: JSON.stringify({}),
headers: { 'content-type': 'application/json' },
})
const res = await freezeContract(req, makeCtx('10'))
expect(res.status).toBe(422)
})
})

describe('DELETE /api/admin/contracts/[id]/freeze', () => {
it('unfreezes a contract', async () => {
asAdmin()
mockSql
.mockResolvedValueOnce([{ id: 10, is_frozen: true }] as never)
.mockResolvedValueOnce([{ id: 10, is_frozen: false, updated_at: new Date() }] as never)

const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', {
method: 'DELETE',
})
const res = await unfreezeContract(req, makeCtx('10'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.contract.is_frozen).toBe(false)
})

it('returns 409 if contract is not frozen', async () => {
asAdmin()
mockSql.mockResolvedValueOnce([{ id: 10, is_frozen: false }] as never)

const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', {
method: 'DELETE',
})
const res = await unfreezeContract(req, makeCtx('10'))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.code).toBe('NOT_FROZEN')
})
})
130 changes: 130 additions & 0 deletions __tests__/admin/disputes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

vi.mock('@/lib/db', () => ({ sql: vi.fn() }))
vi.mock('@/lib/auth/session', () => ({
readAccessToken: vi.fn(),
verifyAccessToken: vi.fn(),
}))
vi.mock('@/lib/admin/audit', () => ({ writeAuditLog: vi.fn() }))

import { sql } from '@/lib/db'
import { readAccessToken, verifyAccessToken } from '@/lib/auth/session'
import { GET as listDisputes } from '@/app/api/admin/disputes/route'
import { GET as getDispute, PATCH as patchDispute } from '@/app/api/admin/disputes/[id]/route'

const mockSql = vi.mocked(sql)
const mockReadToken = vi.mocked(readAccessToken)
const mockVerifyToken = vi.mocked(verifyAccessToken)

function asAdmin() {
mockReadToken.mockReturnValue('token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti' })
// First sql call in checkAdmin/withAdmin resolves the admin user
mockSql.mockResolvedValueOnce([{ id: 1, user_type: 'admin', is_banned: false }] as never)
}

function makeCtx(id: string) {
return { params: Promise.resolve({ id }) }
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('GET /api/admin/disputes', () => {
it('returns list of disputes', async () => {
asAdmin()
const disputes = [{ id: 1, status: 'open', reason: 'test' }]
mockSql
.mockResolvedValueOnce(disputes as never)
.mockResolvedValueOnce([{ total: 1 }] as never)

const req = new NextRequest('http://localhost/api/admin/disputes')
const res = await listDisputes(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.disputes).toHaveLength(1)
expect(body.pagination.total).toBe(1)
})

it('rejects invalid status filter', async () => {
asAdmin()
const req = new NextRequest('http://localhost/api/admin/disputes?status=bogus')
const res = await listDisputes(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('INVALID_STATUS')
})
})

describe('GET /api/admin/disputes/[id]', () => {
it('returns 404 for unknown dispute', async () => {
asAdmin()
mockSql.mockResolvedValueOnce([] as never)
const req = new NextRequest('http://localhost/api/admin/disputes/999')
const res = await getDispute(req, makeCtx('999'))
expect(res.status).toBe(404)
})

it('returns dispute detail', async () => {
asAdmin()
const dispute = { id: 5, status: 'open', job_title: 'Fix bug' }
mockSql.mockResolvedValueOnce([dispute] as never)
const req = new NextRequest('http://localhost/api/admin/disputes/5')
const res = await getDispute(req, makeCtx('5'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.dispute.id).toBe(5)
})

it('returns 400 for non-numeric id', async () => {
asAdmin()
const req = new NextRequest('http://localhost/api/admin/disputes/abc')
const res = await getDispute(req, makeCtx('abc'))
expect(res.status).toBe(400)
})
})

describe('PATCH /api/admin/disputes/[id]', () => {
it('updates dispute status', async () => {
asAdmin()
mockSql
.mockResolvedValueOnce([{ id: 3 }] as never) // existing check
.mockResolvedValueOnce([{ id: 3, status: 'resolved', resolution: 'Closed', resolved_at: new Date(), updated_at: new Date() }] as never) // update

const req = new NextRequest('http://localhost/api/admin/disputes/3', {
method: 'PATCH',
body: JSON.stringify({ status: 'resolved', resolution: 'Closed by admin' }),
headers: { 'content-type': 'application/json' },
})
const res = await patchDispute(req, makeCtx('3'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.dispute.status).toBe('resolved')
})

it('returns 422 on invalid body', async () => {
asAdmin()
const req = new NextRequest('http://localhost/api/admin/disputes/3', {
method: 'PATCH',
body: JSON.stringify({ status: 'invalid_status' }),
headers: { 'content-type': 'application/json' },
})
const res = await patchDispute(req, makeCtx('3'))
expect(res.status).toBe(422)
})

it('returns 400 when body is empty of updates', async () => {
asAdmin()
const req = new NextRequest('http://localhost/api/admin/disputes/3', {
method: 'PATCH',
body: JSON.stringify({}),
headers: { 'content-type': 'application/json' },
})
const res = await patchDispute(req, makeCtx('3'))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('EMPTY_UPDATE')
})
})
19 changes: 19 additions & 0 deletions __tests__/admin/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest } from 'next/server'

export function makeRequest(
method: string,
url: string,
body?: unknown,
headers: Record<string, string> = {}
): NextRequest {
const init: RequestInit = { method, headers }
if (body !== undefined) {
init.body = JSON.stringify(body)
;(init.headers as Record<string, string>)['content-type'] = 'application/json'
}
return new NextRequest(url, init)
}

export function adminToken() {
return 'Bearer valid-admin-token'
}
113 changes: 113 additions & 0 deletions __tests__/admin/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'

vi.mock('@/lib/db', () => ({
sql: vi.fn(),
}))

vi.mock('@/lib/auth/session', () => ({
readAccessToken: vi.fn(),
verifyAccessToken: vi.fn(),
}))

import { sql } from '@/lib/db'
import { readAccessToken, verifyAccessToken } from '@/lib/auth/session'
import { checkAdmin, withAdmin } from '@/lib/auth/admin-middleware'

const mockSql = vi.mocked(sql)
const mockReadToken = vi.mocked(readAccessToken)
const mockVerifyToken = vi.mocked(verifyAccessToken)

function makeReq(url = 'http://localhost/api/admin/test') {
return new NextRequest(url)
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('checkAdmin', () => {
it('returns 401 when no token present', async () => {
mockReadToken.mockReturnValue(null)
const result = await checkAdmin(makeReq())
expect(result).toBeInstanceOf(NextResponse)
const res = result as NextResponse
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})

it('returns 401 when token is invalid', async () => {
mockReadToken.mockReturnValue('bad-token')
mockVerifyToken.mockReturnValue(null)
const result = await checkAdmin(makeReq())
expect(result).toBeInstanceOf(NextResponse)
const res = result as NextResponse
expect(res.status).toBe(401)
})

it('returns 403 when user is not admin', async () => {
mockReadToken.mockReturnValue('valid-token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GTEST', jti: 'jti1' })
mockSql.mockResolvedValue([{ id: 1, user_type: 'client', is_banned: false }] as never)
const result = await checkAdmin(makeReq())
expect(result).toBeInstanceOf(NextResponse)
const res = result as NextResponse
expect(res.status).toBe(403)
const body = await res.json()
expect(body.code).toBe('ADMIN_REQUIRED')
})

it('returns 403 when admin user is banned', async () => {
mockReadToken.mockReturnValue('valid-token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti2' })
mockSql.mockResolvedValue([{ id: 2, user_type: 'admin', is_banned: true }] as never)
const result = await checkAdmin(makeReq())
expect(result).toBeInstanceOf(NextResponse)
const res = result as NextResponse
expect(res.status).toBe(403)
const body = await res.json()
expect(body.code).toBe('ACCOUNT_BANNED')
})

it('returns AdminContext for valid admin', async () => {
mockReadToken.mockReturnValue('valid-token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti3' })
mockSql.mockResolvedValue([{ id: 5, user_type: 'admin', is_banned: false }] as never)
const result = await checkAdmin(makeReq())
expect(result).not.toBeInstanceOf(NextResponse)
const ctx = result as Awaited<ReturnType<typeof checkAdmin>>
if (ctx instanceof NextResponse) throw new Error('unexpected')
expect(ctx.userId).toBe(5)
expect(ctx.walletAddress).toBe('GADMIN')
})
})

describe('withAdmin HOF', () => {
it('calls handler with admin context on valid admin', async () => {
mockReadToken.mockReturnValue('valid-token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti4' })
mockSql.mockResolvedValue([{ id: 7, user_type: 'admin', is_banned: false }] as never)

const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true }))
const route = withAdmin(handler)
const req = makeReq()
const res = await route(req)

expect(handler).toHaveBeenCalledOnce()
expect(res.status).toBe(200)
})

it('does not call handler when not admin', async () => {
mockReadToken.mockReturnValue('valid-token')
mockVerifyToken.mockReturnValue({ walletAddress: 'GUSER', jti: 'jti5' })
mockSql.mockResolvedValue([{ id: 3, user_type: 'freelancer', is_banned: false }] as never)

const handler = vi.fn()
const route = withAdmin(handler)
const res = await route(makeReq())

expect(handler).not.toHaveBeenCalled()
expect(res.status).toBe(403)
})
})
Loading
Loading