diff --git a/src/routes.ts b/src/routes.ts index b561804..a9ed98f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -21,16 +21,12 @@ import { Router } from 'express' import type { RouteMount } from './types/routes.js' import { - saveBundle, getBundle, getAllBundles, - saveCID, getCIDsByNonce, - updateBalanceForOneToken, getBalanceForOneToken, getBalanceForAllTokens, getVaultNonce, - setVaultNonce, healthCheck, detailedHealthCheck, getInfo, @@ -40,10 +36,6 @@ import { getVaultIdsByController, getControllersByVaultId, getRulesByVaultId, - addControllerToVault, - removeControllerFromVault, - setRulesForVault, - createVault, } from './controllers.js' /** @@ -78,27 +70,21 @@ export const routeMounts: RouteMount[] = [ }, { basePath: '/bundle', - router: Router() - .post('/', saveBundle) - .get('/:nonce', getBundle) - .get('/', getAllBundles), + router: Router().get('/:nonce', getBundle).get('/', getAllBundles), }, { basePath: '/cid', - router: Router().post('/', saveCID).get('/:nonce', getCIDsByNonce), + router: Router().get('/:nonce', getCIDsByNonce), }, { basePath: '/balance', router: Router() - .post('/', updateBalanceForOneToken) .get('/:vault/:token', getBalanceForOneToken) .get('/:vault', getBalanceForAllTokens), }, { basePath: '/nonce', - router: Router() - .get('/:vault', getVaultNonce) - .post('/:vault', setVaultNonce), + router: Router().get('/:vault', getVaultNonce), }, { basePath: '/filecoin', @@ -107,12 +93,8 @@ export const routeMounts: RouteMount[] = [ { basePath: '/vault', router: Router() - .post('/:vaultId', createVault) .get('/by-controller/:address', getVaultIdsByController) .get('/:vaultId/controllers', getControllersByVaultId) - .get('/:vaultId/rules', getRulesByVaultId) - .post('/:vaultId/controllers/add', addControllerToVault) - .post('/:vaultId/controllers/remove', removeControllerFromVault) - .post('/:vaultId/rules', setRulesForVault), + .get('/:vaultId/rules', getRulesByVaultId), }, ] diff --git a/test/helpers/testFixtures.ts b/test/helpers/testFixtures.ts new file mode 100644 index 0000000..f811bd6 --- /dev/null +++ b/test/helpers/testFixtures.ts @@ -0,0 +1,74 @@ +/** + * ╔═══════════════════════════════════════════════════════════════════════════╗ + * ║ 🌪️ OYA PROTOCOL NODE 🌪️ ║ + * ║ Test Fixtures & Constants ║ + * ╚═══════════════════════════════════════════════════════════════════════════╝ + * + * Shared test data and constants used across test suites. + */ + +/** + * Sample vault ID for testing (valid 32-byte hex string with 0x prefix). + */ +export const TEST_VAULT_ID = + '0x1234567890123456789012345678901234567890123456789012345678901234' + +/** + * Sample Ethereum address for testing (lowercase to avoid checksum validation). + */ +export const TEST_ADDRESS = '0x742d35cc6634c0532925a3b844bc9e7595f0beb' + +/** + * Additional test addresses for multi-party scenarios. + */ +export const TEST_ADDRESS_2 = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +export const TEST_ADDRESS_3 = '0xcccccccccccccccccccccccccccccccccccccccc' + +/** + * Real mainnet token addresses for testing (lowercase, always valid). + */ +export const USDC_ADDRESS = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +export const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + +/** + * Sample CID for testing. + */ +export const TEST_CID = + 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + +/** + * All POST endpoints that require authentication. + * Only /intention is publicly accessible via POST. + * Other write operations are internal-only (not exposed via HTTP). + */ +export const POST_ENDPOINTS = ['/intention'] + +/** + * All GET endpoints that should NOT require authentication. + * Used for testing that public endpoints remain accessible. + */ +export const GET_ENDPOINTS = [ + '/health', + '/info', + '/metrics', + '/bundle', + '/bundle/0', + '/cid/0', + `/balance/${TEST_VAULT_ID}`, + `/nonce/${TEST_VAULT_ID}`, + `/vault/${TEST_VAULT_ID}/controllers`, + `/vault/${TEST_VAULT_ID}/rules`, + '/vault/by-controller/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + `/filecoin/status/${TEST_CID}`, +] + +/** + * Sample valid intention payload for testing. + */ +export const SAMPLE_INTENTION = { + from: TEST_ADDRESS, + to: TEST_ADDRESS, + intention: 'test intention', + vaultId: TEST_VAULT_ID, + signature: '0x' + '0'.repeat(130), // Dummy signature +} diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts new file mode 100644 index 0000000..a742f4d --- /dev/null +++ b/test/integration/auth.test.ts @@ -0,0 +1,332 @@ +/** + * ╔═══════════════════════════════════════════════════════════════════════════╗ + * ║ 🌪️ OYA PROTOCOL NODE 🌪️ ║ + * ║ Authentication Integration Tests ║ + * ╚═══════════════════════════════════════════════════════════════════════════╝ + * + * Comprehensive tests for Bearer token authentication middleware. + * Tests protection of POST endpoints and proper error handling. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { + startTestServer, + stopTestServer, + request, +} from '../helpers/testServer.js' +import { + POST_ENDPOINTS, + GET_ENDPOINTS, + SAMPLE_INTENTION, +} from '../helpers/testFixtures.js' +import type { Server } from 'http' + +describe('Authentication Middleware', () => { + let server: Server + let baseURL: string + const validToken = process.env.API_BEARER_TOKEN || 'test-token' + + beforeAll(async () => { + const testServer = await startTestServer() + server = testServer.server + baseURL = testServer.baseURL + }) + + afterAll(async () => { + await stopTestServer(server) + }) + + describe('Bearer Token Validation', () => { + it('should allow POST request with valid Bearer token', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(SAMPLE_INTENTION), + }) + + // Should not return 401 or 403 (may return other errors due to validation, that's ok) + expect(response.status).not.toBe(401) + expect(response.status).not.toBe(403) + }) + + it('should reject POST request with missing Authorization header', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Missing Authorization header') + }) + + it('should reject POST request with invalid Bearer token', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: 'Bearer invalid-token-12345', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with wrong authentication scheme', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Basic ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with malformed Authorization header (no space)', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with only "Bearer" and no token', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: 'Bearer', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with empty Authorization header', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: '', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Missing Authorization header') + }) + + it('should reject POST request with token that has extra whitespace', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + // Should fail because token includes leading space + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with case-sensitive scheme mismatch', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with partial token match', async () => { + const partialToken = validToken.substring(0, validToken.length - 5) + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${partialToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should reject POST request with token plus extra characters', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}extra`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + }) + + describe('POST Endpoint Protection', () => { + it('should protect all POST endpoints', async () => { + for (const endpoint of POST_ENDPOINTS) { + const response = await fetch(`${baseURL}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(401) + } + }) + }) + + describe('GET Endpoint Access (No Auth Required)', () => { + it('should allow all GET requests without authentication', async () => { + for (const endpoint of GET_ENDPOINTS) { + const response = await fetch(`${baseURL}${endpoint}`, { + method: 'GET', + }) + + // Should never return auth errors (401/403) + expect(response.status).not.toBe(401) + expect(response.status).not.toBe(403) + } + }) + + it('should allow GET /health/detailed without authentication (may fail DB check)', async () => { + const response = await request(baseURL, '/health/detailed', { + method: 'GET', + }) + + // Should not return 401/403 (may return 503 if DB health check fails) + expect(response.status).not.toBe(401) + expect(response.status).not.toBe(403) + }) + }) + + describe('Edge Cases', () => { + it('should reject very long invalid tokens (DoS protection)', async () => { + // Tests that token comparison doesn't have pathological performance with long strings + const longInvalidToken = 'x'.repeat(10000) + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${longInvalidToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Invalid or missing token') + }) + + it('should handle multiple spaces in Authorization header', async () => { + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + // Should fail because split(' ') produces empty string as token + expect(response.status).toBe(403) + }) + }) + + describe('Stateless Authentication', () => { + it('should authenticate each request independently', async () => { + // Verify that valid auth token works on multiple requests + const response1 = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ...SAMPLE_INTENTION, intention: 'test 1' }), + }) + + expect(response1.status).not.toBe(401) + expect(response1.status).not.toBe(403) + + // Second request should also succeed with same token + const response2 = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ...SAMPLE_INTENTION, intention: 'test 2' }), + }) + + expect(response2.status).not.toBe(401) + expect(response2.status).not.toBe(403) + }) + + it('should not persist authentication without header (no session)', async () => { + // First request with auth + await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + Authorization: `Bearer ${validToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(SAMPLE_INTENTION), + }) + + // Second request without auth header - should fail + const response = await fetch(`${baseURL}/intention`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(401) + }) + }) +})