From da8259be0f2fa4edc8c9f15111877dce82fc5f4b Mon Sep 17 00:00:00 2001 From: kagemnikarimu <82295340+KagemniKarimu@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:53:10 -0500 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20add=20shared=20test=20fixtures?= =?UTF-8?q?=20and=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test addresses, vault IDs, and endpoint lists - Centralized test data for reuse across test suites --- test/helpers/fixtures.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/helpers/fixtures.ts diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts new file mode 100644 index 0000000..ddc5371 --- /dev/null +++ b/test/helpers/fixtures.ts @@ -0,0 +1,82 @@ +/** + * ╔═══════════════════════════════════════════════════════════════════════════╗ + * ║ 🌪️ 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. + * Used for testing that auth middleware protects state-modifying operations. + */ +export const POST_ENDPOINTS = [ + '/intention', + '/bundle', + '/cid', + '/balance', + `/nonce/${TEST_VAULT_ID}`, + `/vault/${TEST_VAULT_ID}`, + `/vault/${TEST_VAULT_ID}/controllers/add`, + `/vault/${TEST_VAULT_ID}/controllers/remove`, + `/vault/${TEST_VAULT_ID}/rules`, +] + +/** + * 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 +} From d381b7f988fa63e357d3b12a628133ae4884987d Mon Sep 17 00:00:00 2001 From: kagemnikarimu <82295340+KagemniKarimu@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:54:40 -0500 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=85=20add=20comprehensive=20authentic?= =?UTF-8?q?ation=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 18 tests covering bearer token validation - Tests for all POST/GET endpoint protection - Edge cases and stateless auth verification - Reduced from 38 to 18 tests via consolidation --- test/helpers/fixtures.ts | 3 +- test/integration/auth.test.ts | 332 ++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 test/integration/auth.test.ts diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index ddc5371..c384fad 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -33,7 +33,8 @@ export const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' /** * Sample CID for testing. */ -export const TEST_CID = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' +export const TEST_CID = + 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' /** * All POST endpoints that require authentication. diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts new file mode 100644 index 0000000..1fda195 --- /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/fixtures.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) + }) + }) +}) From 3ff314d58741589dc19b5fea72032a45ce5dbe54 Mon Sep 17 00:00:00 2001 From: kagemnikarimu <82295340+KagemniKarimu@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:14:59 -0500 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20fixtures.ts?= =?UTF-8?q?=20to=20testFixtures.ts=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/helpers/{fixtures.ts => testFixtures.ts} | 0 test/integration/auth.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/helpers/{fixtures.ts => testFixtures.ts} (100%) diff --git a/test/helpers/fixtures.ts b/test/helpers/testFixtures.ts similarity index 100% rename from test/helpers/fixtures.ts rename to test/helpers/testFixtures.ts diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts index 1fda195..a742f4d 100644 --- a/test/integration/auth.test.ts +++ b/test/integration/auth.test.ts @@ -18,7 +18,7 @@ import { POST_ENDPOINTS, GET_ENDPOINTS, SAMPLE_INTENTION, -} from '../helpers/fixtures.js' +} from '../helpers/testFixtures.js' import type { Server } from 'http' describe('Authentication Middleware', () => { From aa18e9c98b21dc7f23b562e030986ab69ddbb307 Mon Sep 17 00:00:00 2001 From: kagemnikarimu <82295340+KagemniKarimu@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:15:23 -0500 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=92=20remove=20internal=20POST=20e?= =?UTF-8?q?ndpoints,=20keep=20only=20/intention=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove POST routes for /bundle, /cid, /balance, /nonce, /vault - Only /intention remains as public POST endpoint - Internal operations (bundle creation, balance updates, etc.) still work via direct function calls - Prevents external services from writing to node database via HTTP - Reduces attack surface while maintaining functionality --- src/routes.ts | 22 +++------------------- test/helpers/testFixtures.ts | 15 +++------------ 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/routes.ts b/src/routes.ts index b561804..25110bd 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' /** @@ -79,26 +71,22 @@ export const routeMounts: RouteMount[] = [ { basePath: '/bundle', router: Router() - .post('/', saveBundle) .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 +95,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 index c384fad..f811bd6 100644 --- a/test/helpers/testFixtures.ts +++ b/test/helpers/testFixtures.ts @@ -38,19 +38,10 @@ export const TEST_CID = /** * All POST endpoints that require authentication. - * Used for testing that auth middleware protects state-modifying operations. + * Only /intention is publicly accessible via POST. + * Other write operations are internal-only (not exposed via HTTP). */ -export const POST_ENDPOINTS = [ - '/intention', - '/bundle', - '/cid', - '/balance', - `/nonce/${TEST_VAULT_ID}`, - `/vault/${TEST_VAULT_ID}`, - `/vault/${TEST_VAULT_ID}/controllers/add`, - `/vault/${TEST_VAULT_ID}/controllers/remove`, - `/vault/${TEST_VAULT_ID}/rules`, -] +export const POST_ENDPOINTS = ['/intention'] /** * All GET endpoints that should NOT require authentication. From 7be1aea0acf13508b3e338c154347c939af6f333 Mon Sep 17 00:00:00 2001 From: kagemnikarimu <82295340+KagemniKarimu@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:23:12 -0500 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=8E=A8=20format=20routes.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/routes.ts b/src/routes.ts index 25110bd..a9ed98f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -70,9 +70,7 @@ export const routeMounts: RouteMount[] = [ }, { basePath: '/bundle', - router: Router() - .get('/:nonce', getBundle) - .get('/', getAllBundles), + router: Router().get('/:nonce', getBundle).get('/', getAllBundles), }, { basePath: '/cid',