diff --git a/__tests__/auth/admin.test.ts b/__tests__/auth/admin.test.ts new file mode 100644 index 0000000..8b79d02 --- /dev/null +++ b/__tests__/auth/admin.test.ts @@ -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) + }) +}) diff --git a/__tests__/auth/constants.test.ts b/__tests__/auth/constants.test.ts new file mode 100644 index 0000000..9ec6b90 --- /dev/null +++ b/__tests__/auth/constants.test.ts @@ -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) + }) + }) +}) diff --git a/__tests__/auth/crypto.test.ts b/__tests__/auth/crypto.test.ts new file mode 100644 index 0000000..7dffedc --- /dev/null +++ b/__tests__/auth/crypto.test.ts @@ -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) + }) + }) +}) diff --git a/__tests__/auth/jwt.test.ts b/__tests__/auth/jwt.test.ts new file mode 100644 index 0000000..ca9594f --- /dev/null +++ b/__tests__/auth/jwt.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest' +import { signSessionToken, verifySessionToken } from '@/lib/auth/jwt' +import { encodeBase64Url } from '@/lib/auth/crypto' +import { createHmac } from 'crypto' + +const TEST_SECRET = 'test-secret-key-that-is-at-least-32-chars-long' + +describe('JWT session tokens', () => { + describe('signSessionToken', () => { + it('returns a valid JWT string', () => { + const result = signSessionToken({ + subject: 'wallet-address', + walletAddress: 'wallet-address', + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + expect(result.token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/) + }) + + it('includes correct payload fields', () => { + const result = signSessionToken({ + subject: 'GABC123', + walletAddress: 'GABC123', + type: 'refresh', + expiresInSeconds: 604800, + secret: TEST_SECRET, + }) + + expect(result.payload.sub).toBe('GABC123') + expect(result.payload.wallet).toBe('GABC123') + expect(result.payload.type).toBe('refresh') + expect(result.payload.jti).toBeTruthy() + expect(result.payload.iat).toBeGreaterThan(0) + expect(result.payload.exp).toBeGreaterThan(result.payload.iat) + }) + + it('generates unique JTIs for each call', () => { + const a = signSessionToken({ + subject: 'w', walletAddress: 'w', type: 'access', + expiresInSeconds: 900, secret: TEST_SECRET, + }) + const b = signSessionToken({ + subject: 'w', walletAddress: 'w', type: 'access', + expiresInSeconds: 900, secret: TEST_SECRET, + }) + expect(a.payload.jti).not.toBe(b.payload.jti) + }) + + it('sets correct expiration time', () => { + const ttl = 120 + const result = signSessionToken({ + subject: 'w', walletAddress: 'w', type: 'access', + expiresInSeconds: ttl, secret: TEST_SECRET, + }) + expect(result.payload.exp - result.payload.iat).toBe(ttl) + }) + }) + + describe('verifySessionToken', () => { + it('verifies a valid token', () => { + const { token } = signSessionToken({ + subject: 'GABC', + walletAddress: 'GABC', + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + const payload = verifySessionToken(token, TEST_SECRET) + expect(payload).not.toBeNull() + expect(payload?.wallet).toBe('GABC') + expect(payload?.type).toBe('access') + }) + + it('rejects token signed with wrong secret', () => { + const { token } = signSessionToken({ + subject: 'GABC', + walletAddress: 'GABC', + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + const payload = verifySessionToken(token, 'wrong-secret-key-32-chars-long!!!!') + expect(payload).toBeNull() + }) + + it('rejects expired token', () => { + const { token } = signSessionToken({ + subject: 'GABC', + walletAddress: 'GABC', + type: 'access', + expiresInSeconds: -10, + secret: TEST_SECRET, + }) + + const result = verifySessionToken(token, TEST_SECRET) + expect(result).toBeNull() + }) + + it('rejects malformed token', () => { + expect(verifySessionToken('not.a.jwt', TEST_SECRET)).toBeNull() + expect(verifySessionToken('', TEST_SECRET)).toBeNull() + expect(verifySessionToken('onlytwo', TEST_SECRET)).toBeNull() + }) + + it('rejects token with tampered payload', () => { + const { token } = signSessionToken({ + subject: 'GABC', + walletAddress: 'GABC', + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + const parts = token.split('.') + parts[1] = parts[1] + 'X' + const tampered = parts.join('.') + + expect(verifySessionToken(tampered, TEST_SECRET)).toBeNull() + }) + + it('returns null when payload parts are empty', () => { + expect(verifySessionToken('..sig', TEST_SECRET)).toBeNull() + expect(verifySessionToken('header..', TEST_SECRET)).toBeNull() + expect(verifySessionToken('.payload.', TEST_SECRET)).toBeNull() + }) + + it('returns null for invalid header (not HS256/JWT)', () => { + const badHeader = encodeBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) + const payload = encodeBase64Url(JSON.stringify({ + sub: 'test', wallet: 'test', jti: 'x', type: 'access', + iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 900, + })) + const input = `${badHeader}.${payload}` + const sig = encodeBase64Url(createHmac('sha256', TEST_SECRET).update(input).digest()) + expect(verifySessionToken(`${input}.${sig}`, TEST_SECRET)).toBeNull() + }) + + it('returns null for invalid payload structure', () => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const badPayload = encodeBase64Url(JSON.stringify({ sub: 123 })) + const input = `${header}.${badPayload}` + const sig = encodeBase64Url(createHmac('sha256', TEST_SECRET).update(input).digest()) + expect(verifySessionToken(`${input}.${sig}`, TEST_SECRET)).toBeNull() + }) + + it('returns null for non-object payload', () => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const badPayload = encodeBase64Url('"just-a-string"') + const input = `${header}.${badPayload}` + const sig = encodeBase64Url(createHmac('sha256', TEST_SECRET).update(input).digest()) + expect(verifySessionToken(`${input}.${sig}`, TEST_SECRET)).toBeNull() + }) + + it('preserves wallet address exactly', () => { + const wallet = 'GABCDEF1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ' + const { token } = signSessionToken({ + subject: wallet, + walletAddress: wallet, + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + const payload = verifySessionToken(token, TEST_SECRET) + expect(payload?.wallet).toBe(wallet) + expect(payload?.sub).toBe(wallet) + }) + }) + + describe('edge cases for parseJson catch (line 69)', () => { + it('returns null when payload decodes to invalid JSON', () => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const invalidPayload = encodeBase64Url('NOT_JSON{{{') + const signingInput = `${header}.${invalidPayload}` + const sig = encodeBase64Url( + require('crypto').createHmac('sha256', TEST_SECRET).update(signingInput).digest() + ) + expect(verifySessionToken(`${signingInput}.${sig}`, TEST_SECRET)).toBeNull() + }) + + it('returns null when payload decodes to binary garbage', () => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const garbage = encodeBase64Url(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0x01])) + const signingInput = `${header}.${garbage}` + const sig = encodeBase64Url( + require('crypto').createHmac('sha256', TEST_SECRET).update(signingInput).digest() + ) + expect(verifySessionToken(`${signingInput}.${sig}`, TEST_SECRET)).toBeNull() + }) + + it('returns null when payload is empty string', () => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const emptyPayload = encodeBase64Url('') + const signingInput = `${header}.${emptyPayload}` + const sig = encodeBase64Url( + require('crypto').createHmac('sha256', TEST_SECRET).update(signingInput).digest() + ) + expect(verifySessionToken(`${signingInput}.${sig}`, TEST_SECRET)).toBeNull() + }) + }) + + describe('token round-trip', () => { + it('access token round-trip', () => { + const { token, payload } = signSessionToken({ + subject: 'GTEST', + walletAddress: 'GTEST', + type: 'access', + expiresInSeconds: 900, + secret: TEST_SECRET, + }) + + const verified = verifySessionToken(token, TEST_SECRET) + expect(verified).not.toBeNull() + expect(verified?.sub).toBe(payload.sub) + expect(verified?.wallet).toBe(payload.wallet) + expect(verified?.jti).toBe(payload.jti) + expect(verified?.type).toBe('access') + expect(verified?.iat).toBe(payload.iat) + expect(verified?.exp).toBe(payload.exp) + }) + + it('refresh token round-trip', () => { + const { token, payload } = signSessionToken({ + subject: 'GTEST', + walletAddress: 'GTEST', + type: 'refresh', + expiresInSeconds: 604800, + secret: TEST_SECRET, + }) + + const verified = verifySessionToken(token, TEST_SECRET) + expect(verified).not.toBeNull() + expect(verified?.type).toBe('refresh') + }) + }) +}) diff --git a/__tests__/auth/middleware.test.ts b/__tests__/auth/middleware.test.ts new file mode 100644 index 0000000..d0a4061 --- /dev/null +++ b/__tests__/auth/middleware.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { signSessionToken } from '@/lib/auth/jwt' + +const VALID_SECRET = 'test-secret-key-that-is-at-least-32-chars-long' + +vi.mock('next/server', () => ({ + NextRequest: vi.fn(), + NextResponse: { + json: vi.fn().mockImplementation((body: any, init?: any) => ({ + status: init?.status ?? 200, + body, + })), + }, +})) + +vi.mock('@/lib/auth/session', () => ({ + readAccessToken: vi.fn(), + verifyAccessToken: vi.fn(), +})) + +import { withAuth } from '@/lib/auth/middleware' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { NextResponse } from 'next/server' + +const mockReadAccessToken = vi.mocked(readAccessToken) +const mockVerifyAccessToken = vi.mocked(verifyAccessToken) + +function makeRequest() { + return { headers: { get: () => null } } as any +} + +describe('withAuth middleware', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(NextResponse.json).mockImplementation(((body: any, init?: any) => ({ + status: init?.status ?? 200, + body, + })) as any) + }) + + it('returns 401 when no token', async () => { + mockReadAccessToken.mockReturnValue(null) + const handler = withAuth(async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(401) + }) + + it('returns 401 when token is invalid', async () => { + mockReadAccessToken.mockReturnValue('bad-token') + mockVerifyAccessToken.mockReturnValue(null) + const handler = withAuth(async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(401) + }) + + it('calls handler with auth context when valid', async () => { + mockReadAccessToken.mockReturnValue('good-token') + mockVerifyAccessToken.mockReturnValue({ walletAddress: 'GABC', jti: 'jti-1' }) + const handler = withAuth(async (_req: any, auth: any) => { + return { wallet: auth.walletAddress, jti: auth.tokenJti } as any + }) + const result = await handler(makeRequest()) + expect(result.wallet).toBe('GABC') + expect(result.jti).toBe('jti-1') + }) +}) diff --git a/__tests__/auth/rbac.test.ts b/__tests__/auth/rbac.test.ts new file mode 100644 index 0000000..6d111f8 --- /dev/null +++ b/__tests__/auth/rbac.test.ts @@ -0,0 +1,241 @@ +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 { withRbac, withAnyRbac, withAllRbac, withRole, checkUserPermission, checkUserAnyPermission, checkUserAllPermissions } from '@/lib/auth/rbacMiddleware' +import { NextResponse } from 'next/server' +import { RbacContext } from '@/lib/auth/rbacMiddleware' + +const mockJson = vi.mocked(NextResponse.json) + +function makeRequest() { + return { headers: { get: () => null } } as any +} + +describe('rbacMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks() + mockJson.mockImplementation(((body: any, init?: any) => ({ + status: init?.status ?? 200, + body, + })) as any) + }) + + describe('withRbac', () => { + it('returns 404 when user not found', async () => { + mockSql.mockResolvedValue([]) + const handler = withRbac('project:view', 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 500 for invalid role', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'INVALID_ROLE' }]) + const handler = withRbac('project:view', async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + expect(result.body.code).toBe('INVALID_ROLE') + }) + + it('returns 403 for insufficient permissions', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }]) + const handler = withRbac('project:create', async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(403) + expect(result.body.code).toBe('INSUFFICIENT_PERMISSIONS') + }) + + it('calls handler with RbacContext when authorized', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'client' }]) + const handler = withRbac('project:create', async (_req: any, auth: RbacContext) => { + return { userId: auth.userId, role: auth.role } as any + }) + const result = await handler(makeRequest()) + expect(result.userId).toBe('u1') + expect(result.role).toBe('client') + }) + + it('returns 500 on DB error', async () => { + mockSql.mockRejectedValue(new Error('DB error')) + const handler = withRbac('project:view', async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + expect(result.body.code).toBe('INTERNAL_ERROR') + }) + }) + + describe('withAnyRbac', () => { + it('returns 404 when user not found', async () => { + mockSql.mockResolvedValue([]) + const handler = withAnyRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(404) + }) + + it('returns 500 for invalid role', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'unknown' }]) + const handler = withAnyRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + expect(result.body.code).toBe('INVALID_ROLE') + }) + + it('returns 403 when no matching permission', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }]) + const handler = withAnyRbac(['project:create', 'admin:users_manage'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(403) + }) + + it('passes when user has one of the permissions', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }]) + const handler = withAnyRbac(['project:view', 'project:create'], async (_req: any, auth: RbacContext) => { + return { userId: auth.userId } as any + }) + const result = await handler(makeRequest()) + expect(result.userId).toBe('u1') + }) + + it('returns 500 on DB error', async () => { + mockSql.mockRejectedValue(new Error('DB error')) + const handler = withAnyRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + }) + }) + + describe('withAllRbac', () => { + it('returns 404 when user not found', async () => { + mockSql.mockResolvedValue([]) + const handler = withAllRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(404) + }) + + it('returns 500 for invalid role', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'NOPE' }]) + const handler = withAllRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + expect(result.body.code).toBe('INVALID_ROLE') + }) + + it('returns 403 when missing required permissions', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }]) + const handler = withAllRbac(['project:view', 'project:create'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(403) + }) + + it('passes when user has all permissions', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'admin' }]) + const handler = withAllRbac(['project:view', 'project:create'], async (_req: any, auth: RbacContext) => { + return { userId: auth.userId } as any + }) + const result = await handler(makeRequest()) + expect(result.userId).toBe('u1') + }) + + it('returns 500 on DB error', async () => { + mockSql.mockRejectedValue(new Error('DB error')) + const handler = withAllRbac(['project:view'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + }) + }) + + describe('withRole', () => { + it('returns 404 when user not found', async () => { + mockSql.mockResolvedValue([]) + const handler = withRole(['admin'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(404) + }) + + it('returns 500 for invalid role', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'INVALID' }]) + const handler = withRole(['admin'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + expect(result.body.code).toBe('INVALID_ROLE') + }) + + it('returns 403 when role not in allowed list', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'freelancer' }]) + const handler = withRole(['admin'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(403) + expect(result.body.code).toBe('INSUFFICIENT_PERMISSIONS') + }) + + it('passes when role is in allowed list', async () => { + mockSql.mockResolvedValue([{ id: 'u1', role: 'admin' }]) + const handler = withRole(['admin'], async (_req: any, auth: RbacContext) => { + return { userId: auth.userId } as any + }) + const result = await handler(makeRequest()) + expect(result.userId).toBe('u1') + }) + + it('returns 500 on DB error', async () => { + mockSql.mockRejectedValue(new Error('DB error')) + const handler = withRole(['admin'], async () => ({ ok: true } as any)) + const result = await handler(makeRequest()) + expect(result.status).toBe(500) + }) + }) + + describe('helper functions', () => { + it('checkUserPermission returns true when role has permission', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'admin' } + expect(checkUserPermission(auth, 'admin:users_manage')).toBe(true) + }) + + it('checkUserPermission returns false when role lacks permission', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'freelancer' } + expect(checkUserPermission(auth, 'admin:users_manage')).toBe(false) + }) + + it('checkUserAnyPermission returns true when role has one', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'freelancer' } + expect(checkUserAnyPermission(auth, ['project:create', 'project:view'])).toBe(true) + }) + + it('checkUserAnyPermission returns false when role has none', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'freelancer' } + expect(checkUserAnyPermission(auth, ['project:create', 'admin:users_manage'])).toBe(false) + }) + + it('checkUserAllPermissions returns true when role has all', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'admin' } + expect(checkUserAllPermissions(auth, ['project:view', 'project:create'])).toBe(true) + }) + + it('checkUserAllPermissions returns false when role misses some', () => { + const auth: RbacContext = { walletAddress: 'GABC', tokenJti: 'jti-1', userId: 'u1', role: 'freelancer' } + expect(checkUserAllPermissions(auth, ['project:view', 'project:create'])).toBe(false) + }) + }) +}) diff --git a/__tests__/auth/session.test.ts b/__tests__/auth/session.test.ts new file mode 100644 index 0000000..edf1fc6 --- /dev/null +++ b/__tests__/auth/session.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => ({ + sql: Object.assign( + vi.fn().mockResolvedValue([]), + { raw: vi.fn() }, + ), +})) + +vi.mock('@/lib/auth/store', () => ({ + storeRefreshToken: vi.fn().mockResolvedValue(undefined), + rotateRefreshToken: vi.fn().mockResolvedValue(true), + revokeRefreshToken: vi.fn().mockResolvedValue(undefined), + findValidRefreshToken: vi.fn().mockResolvedValue(true), + touchRefreshToken: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('next/server', () => ({ + NextRequest: vi.fn(), + NextResponse: vi.fn().mockImplementation(() => ({ + cookies: { + set: vi.fn(), + get: vi.fn(), + }, + json: vi.fn(), + })), +})) + +const VALID_SECRET = 'test-secret-key-that-is-at-least-32-chars-long' + +import { + createSession, + rotateSession, + revokeSession, + readAccessToken, + readRefreshToken, + verifyAccessToken, + setSessionCookies, + clearSessionCookies, +} from '@/lib/auth/session' +import { signSessionToken } from '@/lib/auth/jwt' +import { storeRefreshToken, rotateRefreshToken, revokeRefreshToken } from '@/lib/auth/store' + +function makeRequest(overrides: Record = {}) { + return { + headers: { + get: (name: string) => overrides[name] ?? null, + }, + cookies: { + get: (name: string) => { + const val = overrides[`cookie:${name}`] + return val !== undefined ? { value: val } : undefined + }, + }, + } as any +} + +describe('session', () => { + beforeEach(() => { + process.env.JWT_SECRET = VALID_SECRET + process.env.NODE_ENV = 'test' + vi.clearAllMocks() + }) + + describe('createSession', () => { + it('creates a session with normalized wallet address', async () => { + const request = makeRequest({ 'user-agent': 'test-agent' }) + const session = await createSession(request, 'gabc123') + expect(session.walletAddress).toBe('GABC123') + expect(session.accessToken).toBeTruthy() + expect(session.refreshToken).toBeTruthy() + expect(session.accessTokenExpiresAt).toBeInstanceOf(Date) + expect(session.refreshTokenExpiresAt).toBeInstanceOf(Date) + expect(session.refreshJti).toBeTruthy() + }) + + it('stores refresh token with metadata', async () => { + const request = makeRequest({ + 'user-agent': 'Mozilla/5.0', + 'x-forwarded-for': '1.2.3.4, 5.6.7.8', + }) + await createSession(request, 'GABC123') + expect(storeRefreshToken).toHaveBeenCalledTimes(1) + const call = (storeRefreshToken as any).mock.calls[0][0] + expect(call.walletAddress).toBe('GABC123') + expect(call.userAgent).toBe('Mozilla/5.0') + expect(call.ipAddress).toBe('1.2.3.4') + }) + + it('uses x-real-ip as fallback for client IP', async () => { + const request = makeRequest({ 'x-real-ip': '9.8.7.6' }) + await createSession(request, 'GABC123') + const call = (storeRefreshToken as any).mock.calls[0][0] + expect(call.ipAddress).toBe('9.8.7.6') + }) + + it('returns null IP when no forwarded-for or x-real-ip', async () => { + const request = makeRequest() + await createSession(request, 'GABC123') + const call = (storeRefreshToken as any).mock.calls[0][0] + expect(call.ipAddress).toBeNull() + }) + + it('throws when JWT_SECRET is not set', async () => { + delete process.env.JWT_SECRET + const request = makeRequest() + await expect(createSession(request, 'GABC123')).rejects.toThrow('JWT_SECRET') + }) + + it('throws when JWT_SECRET is too short', async () => { + process.env.JWT_SECRET = 'short' + const request = makeRequest() + await expect(createSession(request, 'GABC123')).rejects.toThrow('JWT_SECRET') + }) + }) + + describe('rotateSession', () => { + it('rotates a valid refresh token', async () => { + const { token } = signSessionToken({ + subject: 'GABC123', + walletAddress: 'GABC123', + type: 'refresh', + expiresInSeconds: 604800, + secret: VALID_SECRET, + }) + const request = makeRequest() + const session = await rotateSession(request, token) + expect(session).not.toBeNull() + expect(session?.walletAddress).toBe('GABC123') + expect(rotateRefreshToken).toHaveBeenCalledTimes(1) + }) + + it('returns null for invalid token', async () => { + const request = makeRequest() + const session = await rotateSession(request, 'invalid.token.here') + expect(session).toBeNull() + }) + + it('returns null for access token (not refresh)', async () => { + const { token } = signSessionToken({ + subject: 'GABC123', + walletAddress: 'GABC123', + type: 'access', + expiresInSeconds: 900, + secret: VALID_SECRET, + }) + const request = makeRequest() + const session = await rotateSession(request, token) + expect(session).toBeNull() + }) + + it('returns null when rotateRefreshToken returns false', async () => { + ;(rotateRefreshToken as any).mockResolvedValueOnce(false) + const { token } = signSessionToken({ + subject: 'GABC123', + walletAddress: 'GABC123', + type: 'refresh', + expiresInSeconds: 604800, + secret: VALID_SECRET, + }) + const request = makeRequest() + const session = await rotateSession(request, token) + expect(session).toBeNull() + }) + }) + + describe('revokeSession', () => { + it('revokes a valid refresh token', async () => { + const { token } = signSessionToken({ + subject: 'GABC123', + walletAddress: 'GABC123', + type: 'refresh', + expiresInSeconds: 604800, + secret: VALID_SECRET, + }) + await revokeSession(token) + expect(revokeRefreshToken).toHaveBeenCalledTimes(1) + }) + + it('does nothing for invalid token', async () => { + await revokeSession('invalid') + expect(revokeRefreshToken).not.toHaveBeenCalled() + }) + + it('does nothing for access token', async () => { + const { token } = signSessionToken({ + subject: 'GABC123', walletAddress: 'GABC123', + type: 'access', expiresInSeconds: 900, secret: VALID_SECRET, + }) + await revokeSession(token) + expect(revokeRefreshToken).not.toHaveBeenCalled() + }) + }) + + describe('readAccessToken', () => { + it('reads token from Authorization header', () => { + const request = makeRequest({ authorization: 'Bearer abc123' }) + expect(readAccessToken(request)).toBe('abc123') + }) + + it('returns null when no Authorization header and no cookie', () => { + const request = makeRequest() + expect(readAccessToken(request)).toBeNull() + }) + + it('reads token from cookie fallback', () => { + const request = makeRequest({ 'cookie:tc_access_token': 'cookie-val' }) + expect(readAccessToken(request)).toBe('cookie-val') + }) + }) + + describe('readRefreshToken', () => { + it('reads refresh token from cookie', () => { + const request = makeRequest({ 'cookie:tc_refresh_token': 'refresh-val' }) + expect(readRefreshToken(request)).toBe('refresh-val') + }) + + it('returns null when no refresh token cookie', () => { + const request = makeRequest() + expect(readRefreshToken(request)).toBeNull() + }) + }) + + describe('verifyAccessToken', () => { + it('verifies a valid access token', () => { + const { token } = signSessionToken({ + subject: 'GABC123', walletAddress: 'GABC123', + type: 'access', expiresInSeconds: 900, secret: VALID_SECRET, + }) + const result = verifyAccessToken(token) + expect(result).not.toBeNull() + expect(result?.walletAddress).toBe('GABC123') + }) + + it('returns null for refresh token', () => { + const { token } = signSessionToken({ + subject: 'GABC123', walletAddress: 'GABC123', + type: 'refresh', expiresInSeconds: 604800, secret: VALID_SECRET, + }) + expect(verifyAccessToken(token)).toBeNull() + }) + + it('returns null for invalid token', () => { + expect(verifyAccessToken('bad')).toBeNull() + }) + }) + + describe('setSessionCookies', () => { + it('sets access and refresh cookies with correct names and attributes', () => { + const response = { + cookies: { set: vi.fn() }, + } as any + const session = { + accessToken: 'acc', + refreshToken: 'ref', + accessTokenExpiresAt: new Date('2026-01-01'), + refreshTokenExpiresAt: new Date('2026-12-31'), + } as any + setSessionCookies(response, session) + expect(response.cookies.set).toHaveBeenCalledTimes(2) + + const calls = response.cookies.set.mock.calls + const accessCookie = calls[0][0] + const refreshCookie = calls[1][0] + + expect(accessCookie.name).toBe('tc_access_token') + expect(accessCookie.value).toBe('acc') + expect(accessCookie.httpOnly).toBe(true) + expect(accessCookie.sameSite).toBe('lax') + expect(accessCookie.path).toBe('/') + expect(accessCookie.expires).toEqual(new Date('2026-01-01')) + + expect(refreshCookie.name).toBe('tc_refresh_token') + expect(refreshCookie.value).toBe('ref') + expect(refreshCookie.httpOnly).toBe(true) + expect(refreshCookie.sameSite).toBe('lax') + expect(refreshCookie.path).toBe('/') + expect(refreshCookie.expires).toEqual(new Date('2026-12-31')) + }) + + it('sets secure=true when NODE_ENV is production', () => { + process.env.NODE_ENV = 'production' + const response = { cookies: { set: vi.fn() } } as any + const session = { + accessToken: 'acc', refreshToken: 'ref', + accessTokenExpiresAt: new Date(), refreshTokenExpiresAt: new Date(), + } as any + setSessionCookies(response, session) + expect(response.cookies.set.mock.calls[0][0].secure).toBe(true) + }) + + it('sets secure=false when NODE_ENV is test', () => { + process.env.NODE_ENV = 'test' + const response = { cookies: { set: vi.fn() } } as any + const session = { + accessToken: 'acc', refreshToken: 'ref', + accessTokenExpiresAt: new Date(), refreshTokenExpiresAt: new Date(), + } as any + setSessionCookies(response, session) + expect(response.cookies.set.mock.calls[0][0].secure).toBe(false) + }) + }) + + describe('clearSessionCookies', () => { + it('clears both cookies with correct names, maxAge 0, and security attributes', () => { + const response = { + cookies: { set: vi.fn() }, + } as any + clearSessionCookies(response) + expect(response.cookies.set).toHaveBeenCalledTimes(2) + + const calls = response.cookies.set.mock.calls + const accessClear = calls[0][0] + const refreshClear = calls[1][0] + + expect(accessClear.name).toBe('tc_access_token') + expect(accessClear.value).toBe('') + expect(accessClear.maxAge).toBe(0) + expect(accessClear.httpOnly).toBe(true) + expect(accessClear.path).toBe('/') + + expect(refreshClear.name).toBe('tc_refresh_token') + expect(refreshClear.value).toBe('') + expect(refreshClear.maxAge).toBe(0) + expect(refreshClear.httpOnly).toBe(true) + expect(refreshClear.path).toBe('/') + }) + }) +}) diff --git a/__tests__/auth/stellar.test.ts b/__tests__/auth/stellar.test.ts new file mode 100644 index 0000000..9df702a --- /dev/null +++ b/__tests__/auth/stellar.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect } from 'vitest' +import { generateKeyPairSync, sign, createPublicKey } from 'crypto' +import { + isValidStellarAddress, + normalizeWalletAddress, + buildAuthMessage, + verifyStellarSignature, + getStellarPublicKey, +} from '@/lib/auth/stellar' + +const VALID_ADDRESS = 'GCXKIXMLDVIPMCN5RR6MAJDXJJS75HVPWIIB5VIK3IU3G7FTLK2IR2GI' + +describe('Stellar wallet verification', () => { + describe('normalizeWalletAddress', () => { + it('uppercases the address', () => { + expect(normalizeWalletAddress('gabc123')).toBe('GABC123') + }) + + it('trims whitespace', () => { + expect(normalizeWalletAddress(' GABC123 ')).toBe('GABC123') + }) + + it('preserves valid uppercase address', () => { + const addr = 'GAXO33U4YR7JIEQ3C5R5JIEQ3C5R5JIEQ3C5R5JIEQ' + expect(normalizeWalletAddress(addr)).toBe(addr) + }) + }) + + describe('isValidStellarAddress', () => { + it('rejects empty string', () => { + expect(isValidStellarAddress('')).toBe(false) + }) + + it('rejects random text with non-base32 chars', () => { + expect(isValidStellarAddress('not-a-stellar-address!')).toBe(false) + }) + + it('rejects address with wrong length', () => { + expect(isValidStellarAddress('GABC')).toBe(false) + }) + + it('accepts the reference valid address', () => { + expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true) + }) + + it('rejects address with invalid base32 characters', () => { + // 'O' and 'I' are not in base32 alphabet (uses 2-7 instead) + expect(isValidStellarAddress('GOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO')).toBe(false) + }) + }) + + describe('getStellarPublicKey', () => { + it('throws for invalid address', () => { + expect(() => getStellarPublicKey('invalid')).toThrow() + }) + + it('throws for empty address', () => { + expect(() => getStellarPublicKey('')).toThrow() + }) + + it('throws for wrong length', () => { + expect(() => getStellarPublicKey('GABC')).toThrow() + }) + }) + + describe('buildAuthMessage', () => { + it('includes wallet address', () => { + const msg = buildAuthMessage('GABC123', 'nonce-123') + expect(msg).toContain('GABC123') + }) + + it('includes nonce', () => { + const msg = buildAuthMessage('GABC123', 'nonce-123') + expect(msg).toContain('nonce-123') + }) + + it('includes TaskChain branding', () => { + const msg = buildAuthMessage('GABC123', 'nonce-123') + expect(msg).toContain('TaskChain') + }) + + it('normalizes wallet address in message', () => { + const msg = buildAuthMessage('gabc123', 'nonce-123') + expect(msg).toContain('GABC123') + }) + + it('produces deterministic output for same inputs', () => { + const a = buildAuthMessage('GABC', 'nonce1') + const b = buildAuthMessage('GABC', 'nonce1') + expect(a).toBe(b) + }) + + it('produces different output for different nonces', () => { + const a = buildAuthMessage('GABC', 'nonce1') + const b = buildAuthMessage('GABC', 'nonce2') + expect(a).not.toBe(b) + }) + }) + + describe('verifyStellarSignature', () => { + it('throws for invalid wallet address (getStellarPublicKey throws)', () => { + expect(() => + verifyStellarSignature({ + walletAddress: 'invalid', + message: 'test', + signature: 'a'.repeat(128), + }) + ).toThrow() + }) + + it('throws for short address', () => { + expect(() => + verifyStellarSignature({ + walletAddress: 'GABC', + message: 'test', + signature: 'a'.repeat(128), + }) + ).toThrow() + }) + + it('returns false for invalid signature length', () => { + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: 'short', + }) + expect(result).toBe(false) + }) + + it('returns false for garbage signature that decodes to wrong length', () => { + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: '!@#$%^&*()', + }) + expect(result).toBe(false) + }) + + it('returns false for hex-prefixed signature with wrong key', () => { + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: '0x' + 'aa'.repeat(64), + }) + expect(result).toBe(false) + }) + + it('returns false for all-zero signature (not a real signature)', () => { + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test message', + signature: '00'.repeat(64), + }) + expect(result).toBe(false) + }) + + it('returns false when signature does not match message', () => { + const sig = Array.from({ length: 64 }, (_, i) => + (i * 37 % 256).toString(16).padStart(2, '0') + ).join('') + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'arbitrary message', + signature: sig, + }) + expect(result).toBe(false) + }) + + it('returns false for base64url signature that is not a real signature', () => { + const b64Sig = Buffer.alloc(64).toString('base64url') + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: b64Sig, + }) + expect(result).toBe(false) + }) + + it('returns false for raw hex signature that is not a real signature', () => { + const hexSig = 'aa'.repeat(64) + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: hexSig, + }) + expect(result).toBe(false) + }) + + it('returns false for plain base64 signature that is not a real signature', () => { + const plainB64 = Buffer.alloc(64).toString('base64') + const result = verifyStellarSignature({ + walletAddress: VALID_ADDRESS, + message: 'test', + signature: plainB64, + }) + expect(result).toBe(false) + }) + + it('accepts a real Ed25519 signature with a matching keypair', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const rawPubKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) + + const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + function crc16Xmodem(data: Uint8Array): number { + let crc = 0x0000 + for (const value of data) { + crc ^= value << 8 + for (let i = 0; i < 8; i++) { + if ((crc & 0x8000) !== 0) crc = (crc << 1) ^ 0x1021 + else crc <<= 1 + crc &= 0xffff + } + } + return crc + } + function toBase32(bytes: Uint8Array): string { + let bits = 0, value = 0, output = '' + for (const byte of bytes) { + value = (value << 8) | byte; bits += 8 + while (bits >= 5) { bits -= 5; output += BASE32_ALPHABET[(value >>> bits) & 0x1f] } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f] + return output + } + + const versionByte = 0x30 + const payload = new Uint8Array(33) + payload[0] = versionByte + payload.set(rawPubKey, 1) + const crc = crc16Xmodem(payload) + const fullAddress = new Uint8Array(35) + fullAddress.set(payload) + fullAddress[33] = crc & 0xff + fullAddress[34] = (crc >> 8) & 0xff + const stellarAddress = toBase32(fullAddress) + + const message = 'TaskChain Authentication\nWallet: ' + stellarAddress + '\nNonce: test-nonce-123' + const msgBuffer = Buffer.from(message, 'utf8') + const signature = sign(null, msgBuffer, privateKey) + const sigHex = signature.toString('hex') + + const result = verifyStellarSignature({ + walletAddress: stellarAddress, + message, + signature: sigHex, + }) + expect(result).toBe(true) + }) + + it('returns false when signature is from a different key', () => { + const kp1 = generateKeyPairSync('ed25519') + const kp2 = generateKeyPairSync('ed25519') + const rawPubKey = kp1.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) + + const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + function crc16Xmodem(data: Uint8Array): number { + let crc = 0x0000 + for (const value of data) { + crc ^= value << 8; for (let i = 0; i < 8; i++) { + if ((crc & 0x8000) !== 0) crc = (crc << 1) ^ 0x1021; else crc <<= 1; crc &= 0xffff + } + } + return crc + } + function toBase32(bytes: Uint8Array): string { + let bits = 0, value = 0, output = '' + for (const byte of bytes) { + value = (value << 8) | byte; bits += 8 + while (bits >= 5) { bits -= 5; output += BASE32_ALPHABET[(value >>> bits) & 0x1f] } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f] + return output + } + + const payload = new Uint8Array(33) + payload[0] = 0x30 + payload.set(rawPubKey, 1) + const crc = crc16Xmodem(payload) + const fullAddress = new Uint8Array(35) + fullAddress.set(payload) + fullAddress[33] = crc & 0xff + fullAddress[34] = (crc >> 8) & 0xff + const stellarAddress = toBase32(fullAddress) + + const message = 'test message' + const signature = sign(null, Buffer.from(message, 'utf8'), kp2.privateKey) + + const result = verifyStellarSignature({ + walletAddress: stellarAddress, + message, + signature: signature.toString('hex'), + }) + expect(result).toBe(false) + }) + }) + + describe('address validation edge cases (version byte and checksum)', () => { + const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + + function crc16Xmodem(data: Uint8Array): number { + let crc = 0x0000 + for (const value of data) { + crc ^= value << 8 + for (let i = 0; i < 8; i++) { + if ((crc & 0x8000) !== 0) crc = (crc << 1) ^ 0x1021 + else crc <<= 1 + crc &= 0xffff + } + } + return crc + } + + function decodeBase32(input: string): Uint8Array { + const normalized = input.trim().toUpperCase().replace(/=+$/g, '') + let bits = 0; let value = 0; const output: number[] = [] + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char) + if (index === -1) throw new Error('Invalid base32 character') + value = (value << 5) | index + bits += 5 + if (bits >= 8) { bits -= 8; output.push((value >>> bits) & 0xff) } + } + return Uint8Array.from(output) + } + + function encodeBase32(bytes: Uint8Array): string { + let bits = 0; let value = 0; let output = '' + for (const byte of bytes) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { bits -= 5; output += BASE32_ALPHABET[(value >>> bits) & 0x1f] } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f] + return output + } + + it('rejects address with wrong version byte', () => { + const decoded = decodeBase32(VALID_ADDRESS) + expect(decoded.length).toBe(35) + const modified = new Uint8Array(decoded) + modified[0] = 0x62 + const payload = modified.subarray(0, 33) + const crc = crc16Xmodem(payload) + modified[33] = crc & 0xff + modified[34] = (crc >> 8) & 0xff + const badAddr = encodeBase32(modified) + expect(badAddr.length).toBe(56) + expect(() => getStellarPublicKey(badAddr)).toThrow('Invalid Stellar address version byte') + }) + + it('rejects address with wrong checksum', () => { + const decoded = decodeBase32(VALID_ADDRESS) + const modified = new Uint8Array(decoded) + modified[0] = 0x30 + const payload = modified.subarray(0, 33) + const crc = crc16Xmodem(payload) + modified[33] = (crc & 0xff) ^ 0xff + modified[34] = ((crc >> 8) & 0xff) ^ 0xff + const badAddr = encodeBase32(modified) + expect(badAddr.length).toBe(56) + expect(() => getStellarPublicKey(badAddr)).toThrow('Invalid Stellar address checksum') + }) + }) +}) diff --git a/__tests__/auth/store.test.ts b/__tests__/auth/store.test.ts new file mode 100644 index 0000000..1cca56a --- /dev/null +++ b/__tests__/auth/store.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSql } = vi.hoisted(() => ({ + mockSql: vi.fn(), +})) + +vi.mock('@/lib/db', () => ({ + sql: Object.assign(mockSql, { raw: vi.fn() }), +})) + +import { + saveNonce, + hasActiveNonce, + consumeNonce, + storeRefreshToken, + revokeRefreshToken, + touchRefreshToken, + findValidRefreshToken, + rotateRefreshToken, +} from '@/lib/auth/store' + +describe('store', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('saveNonce', () => { + it('inserts a nonce record', async () => { + mockSql.mockResolvedValue([]) + await saveNonce({ + walletAddress: 'GABC', + nonceHash: 'hash123', + expiresAt: new Date('2026-01-01T00:00:00Z'), + }) + expect(mockSql).toHaveBeenCalledTimes(1) + }) + }) + + describe('hasActiveNonce', () => { + it('returns true when nonce exists', async () => { + mockSql.mockResolvedValue([{ id: 1 }]) + const result = await hasActiveNonce({ + walletAddress: 'GABC', + nonceHash: 'hash123', + }) + expect(result).toBe(true) + }) + + it('returns false when nonce does not exist', async () => { + mockSql.mockResolvedValue([]) + const result = await hasActiveNonce({ + walletAddress: 'GABC', + nonceHash: 'hash123', + }) + expect(result).toBe(false) + }) + }) + + describe('consumeNonce', () => { + it('returns true when nonce consumed', async () => { + mockSql.mockResolvedValue([{ id: 1 }]) + const result = await consumeNonce({ + walletAddress: 'GABC', + nonceHash: 'hash123', + }) + expect(result).toBe(true) + }) + + it('returns false when nonce not found', async () => { + mockSql.mockResolvedValue([]) + const result = await consumeNonce({ + walletAddress: 'GABC', + nonceHash: 'hash123', + }) + expect(result).toBe(false) + }) + }) + + describe('storeRefreshToken', () => { + it('inserts a refresh token record', async () => { + mockSql.mockResolvedValue([]) + await storeRefreshToken({ + walletAddress: 'GABC', + jti: 'jti-123', + tokenHash: 'hash', + expiresAt: new Date('2026-01-01'), + userAgent: 'Mozilla/5.0', + ipAddress: '1.2.3.4', + }) + expect(mockSql).toHaveBeenCalledTimes(1) + }) + + it('handles null userAgent and ipAddress', async () => { + mockSql.mockResolvedValue([]) + await storeRefreshToken({ + walletAddress: 'GABC', + jti: 'jti-123', + tokenHash: 'hash', + expiresAt: new Date('2026-01-01'), + userAgent: null, + ipAddress: null, + }) + expect(mockSql).toHaveBeenCalledTimes(1) + }) + }) + + describe('revokeRefreshToken', () => { + it('revokes a token by jti', async () => { + mockSql.mockResolvedValue([]) + await revokeRefreshToken({ jti: 'jti-123' }) + expect(mockSql).toHaveBeenCalledTimes(1) + }) + + it('revokes with replacedByJti', async () => { + mockSql.mockResolvedValue([]) + await revokeRefreshToken({ jti: 'jti-old', replacedByJti: 'jti-new' }) + expect(mockSql).toHaveBeenCalledTimes(1) + }) + }) + + describe('touchRefreshToken', () => { + it('updates last_used_at', async () => { + mockSql.mockResolvedValue([]) + await touchRefreshToken('jti-123') + expect(mockSql).toHaveBeenCalledTimes(1) + }) + }) + + describe('findValidRefreshToken', () => { + it('returns true when valid token found', async () => { + mockSql.mockResolvedValue([{ id: 1 }]) + const result = await findValidRefreshToken({ + walletAddress: 'GABC', + jti: 'jti-123', + tokenHash: 'hash', + }) + expect(result).toBe(true) + }) + + it('returns false when no valid token found', async () => { + mockSql.mockResolvedValue([]) + const result = await findValidRefreshToken({ + walletAddress: 'GABC', + jti: 'jti-123', + tokenHash: 'hash', + }) + expect(result).toBe(false) + }) + }) + + describe('rotateRefreshToken', () => { + it('returns true when rotation succeeded', async () => { + mockSql.mockResolvedValue([{ rotated: '1' }]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: 'Mozilla', + ipAddress: '1.2.3.4', + }) + expect(result).toBe(true) + }) + + it('returns false when rotation failed', async () => { + mockSql.mockResolvedValue([{ rotated: '0' }]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: null, + ipAddress: null, + }) + expect(result).toBe(false) + }) + + it('returns false when no rows returned', async () => { + mockSql.mockResolvedValue([]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: 'test', + ipAddress: '1.2.3.4', + }) + expect(result).toBe(false) + }) + + it('returns false when rotated is numeric 0', async () => { + mockSql.mockResolvedValue([{ rotated: 0 }]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: 'test', + ipAddress: '1.2.3.4', + }) + expect(result).toBe(false) + }) + + it('returns false when rotated is numeric 1 with count > 1', async () => { + mockSql.mockResolvedValue([{ rotated: 2 }]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: 'test', + ipAddress: '1.2.3.4', + }) + expect(result).toBe(false) + }) + + it('returns true when rotated is numeric 1', async () => { + mockSql.mockResolvedValue([{ rotated: 1 }]) + const result = await rotateRefreshToken({ + walletAddress: 'GABC', + currentJti: 'old-jti', + currentTokenHash: 'old-hash', + newJti: 'new-jti', + newTokenHash: 'new-hash', + newExpiresAt: new Date('2026-01-01'), + userAgent: 'test', + ipAddress: '1.2.3.4', + }) + expect(result).toBe(true) + }) + }) +})