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
83 changes: 83 additions & 0 deletions __tests__/auth/admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { mockSql } = vi.hoisted(() => ({
mockSql: vi.fn(),
}))

vi.mock('next/server', () => ({
NextRequest: vi.fn(),
NextResponse: {
json: vi.fn().mockImplementation((body: any, init?: any) => ({
status: init?.status ?? 200,
body,
})),
},
}))

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

vi.mock('@/lib/auth/session', () => ({
readAccessToken: vi.fn().mockReturnValue('token'),
verifyAccessToken: vi.fn().mockReturnValue({ walletAddress: 'GABC', jti: 'jti-1' }),
}))

import { withAdmin } from '@/lib/auth/adminMiddleware'
import { NextResponse } from 'next/server'

const mockJson = vi.mocked(NextResponse.json)

function makeRequest() {
return { headers: { get: () => null } } as any
}

describe('withAdmin', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJson.mockImplementation(((body: any, init?: any) => ({
status: init?.status ?? 200,
body,
})) as any)
})

it('returns 404 when user not found', async () => {
mockSql.mockResolvedValue([])
const handler = withAdmin(async () => ({ ok: true } as any))
const result = await handler(makeRequest())
expect(result.status).toBe(404)
expect(result.body.code).toBe('USER_NOT_FOUND')
})

it('returns 403 when user is not admin', async () => {
mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }])
const handler = withAdmin(async () => ({ ok: true } as any))
const result = await handler(makeRequest())
expect(result.status).toBe(403)
})

it('returns 500 for invalid role', async () => {
mockSql.mockResolvedValue([{ id: 'u1', role: 'invalid_role' }])
const handler = withAdmin(async () => ({ ok: true } as any))
const result = await handler(makeRequest())
expect(result.status).toBe(500)
expect(result.body.code).toBe('INVALID_ROLE')
})

it('passes when user is admin', async () => {
mockSql.mockResolvedValue([{ id: 'u1', role: 'admin' }])
const handler = withAdmin(async (_req: any, auth: any) => {
return { role: auth.role, userId: auth.userId } as any
})
const result = await handler(makeRequest())
expect(result.role).toBe('admin')
expect(result.userId).toBe('u1')
})

it('returns 500 on DB error', async () => {
mockSql.mockRejectedValue(new Error('DB error'))
const handler = withAdmin(async () => ({ ok: true } as any))
const result = await handler(makeRequest())
expect(result.status).toBe(500)
})
})
86 changes: 86 additions & 0 deletions __tests__/auth/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import {
hasPermission,
hasAnyPermission,
hasAllPermissions,
ROLE_PERMISSIONS,
} from '@/lib/auth/constants'

describe('constants', () => {
describe('hasPermission', () => {
it('returns true for freelancer with project:view', () => {
expect(hasPermission('freelancer', 'project:view')).toBe(true)
})

it('returns false for freelancer with project:create', () => {
expect(hasPermission('freelancer', 'project:create')).toBe(false)
})

it('returns true for client with project:create', () => {
expect(hasPermission('client', 'project:create')).toBe(true)
})

it('returns true for admin with any permission', () => {
expect(hasPermission('admin', 'admin:users_manage')).toBe(true)
expect(hasPermission('admin', 'dispute:resolve')).toBe(true)
expect(hasPermission('admin', 'project:create')).toBe(true)
})

it('returns false for freelancer with admin permissions', () => {
expect(hasPermission('freelancer', 'admin:users_manage')).toBe(false)
expect(hasPermission('freelancer', 'admin:system_oversight')).toBe(false)
})
})

describe('hasAnyPermission', () => {
it('returns true when user has at least one permission', () => {
expect(hasAnyPermission('freelancer', ['project:create', 'project:view'])).toBe(true)
})

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

it('returns true when admin has any permissions', () => {
expect(hasAnyPermission('admin', ['admin:users_manage', 'dispute:resolve'])).toBe(true)
})

it('returns true for client with escrow permissions', () => {
expect(hasAnyPermission('client', ['escrow:fund', 'escrow:release'])).toBe(true)
})
})

describe('hasAllPermissions', () => {
it('returns true when user has all permissions', () => {
expect(hasAllPermissions('admin', ['project:create', 'dispute:resolve', 'admin:users_manage'])).toBe(true)
})

it('returns false when user is missing at least one', () => {
expect(hasAllPermissions('freelancer', ['project:view', 'project:create'])).toBe(false)
})

it('returns true for client with all client permissions', () => {
expect(hasAllPermissions('client', ['project:create', 'project:update', 'project:delete'])).toBe(true)
})

it('returns true for freelancer with all freelancer permissions', () => {
expect(hasAllPermissions('freelancer', ['project:view', 'milestone:view', 'milestone:submit'])).toBe(true)
})
})

describe('ROLE_PERMISSIONS', () => {
it('has all three roles defined', () => {
expect(ROLE_PERMISSIONS).toHaveProperty('freelancer')
expect(ROLE_PERMISSIONS).toHaveProperty('client')
expect(ROLE_PERMISSIONS).toHaveProperty('admin')
})

it('admin has more permissions than freelancer', () => {
expect(ROLE_PERMISSIONS.admin.length).toBeGreaterThan(ROLE_PERMISSIONS.freelancer.length)
})

it('admin has more permissions than client', () => {
expect(ROLE_PERMISSIONS.admin.length).toBeGreaterThan(ROLE_PERMISSIONS.client.length)
})
})
})
119 changes: 119 additions & 0 deletions __tests__/auth/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import {
sha256Hex,
fromBase64Url,
encodeBase64Url,
randomNonce,
randomId,
safeEqual,
} from '@/lib/auth/crypto'

describe('crypto utilities', () => {
describe('sha256Hex', () => {
it('returns consistent hash for same input', () => {
const a = sha256Hex('hello')
const b = sha256Hex('hello')
expect(a).toBe(b)
})

it('returns 64-char hex string', () => {
const hash = sha256Hex('test')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})

it('returns different hashes for different inputs', () => {
expect(sha256Hex('a')).not.toBe(sha256Hex('b'))
})

it('hashes empty string', () => {
const hash = sha256Hex('')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
})

describe('base64url round-trip', () => {
it('encodes and decodes buffer correctly', () => {
const original = Buffer.from('Hello, World!')
const encoded = encodeBase64Url(original)
const decoded = fromBase64Url(encoded)
expect(decoded).toEqual(original)
})

it('encodes and decodes string correctly', () => {
const original = 'test-value-123'
const encoded = encodeBase64Url(original)
const decoded = fromBase64Url(encoded)
expect(decoded.toString('utf8')).toBe(original)
})

it('uses URL-safe characters (no + / =)', () => {
const encoded = encodeBase64Url(Buffer.from('special chars: <>?{}[]'))
expect(encoded).not.toContain('+')
expect(encoded).not.toContain('/')
expect(encoded).not.toContain('=')
})

it('handles empty input', () => {
const encoded = encodeBase64Url(Buffer.alloc(0))
expect(encoded).toBe('')
const decoded = fromBase64Url(encoded)
expect(decoded.length).toBe(0)
})
})

describe('randomNonce', () => {
it('returns unique values', () => {
const nonces = new Set(Array.from({ length: 50 }, () => randomNonce()))
expect(nonces.size).toBe(50)
})

it('returns non-empty string', () => {
expect(randomNonce().length).toBeGreaterThan(0)
})

it('accepts custom byte size', () => {
const small = randomNonce(8)
const large = randomNonce(64)
expect(large.length).toBeGreaterThan(small.length)
})
})

describe('randomId', () => {
it('returns unique values', () => {
const ids = new Set(Array.from({ length: 50 }, () => randomId()))
expect(ids.size).toBe(50)
})

it('returns non-empty string', () => {
expect(randomId().length).toBeGreaterThan(0)
})
})

describe('safeEqual', () => {
it('returns true for equal strings', () => {
expect(safeEqual('abc', 'abc')).toBe(true)
})

it('returns false for different strings', () => {
expect(safeEqual('abc', 'def')).toBe(false)
})

it('returns false for different lengths', () => {
expect(safeEqual('abc', 'abcd')).toBe(false)
})

it('returns false for empty vs non-empty', () => {
expect(safeEqual('', 'a')).toBe(false)
})

it('returns true for empty strings', () => {
expect(safeEqual('', '')).toBe(true)
})

it('is timing-safe (does not short-circuit on first char)', () => {
// Both strings have same length but different content
expect(safeEqual('aaaa', 'aaab')).toBe(false)
expect(safeEqual('aaaa', 'aaaa')).toBe(true)
})
})
})
Loading
Loading