diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index afe227ec..6949696d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,4 +1,4 @@ -name: E2E Test +name: e2e concurrency: group: "${{ github.workflow }}-${{ github.ref }}" @@ -20,9 +20,32 @@ env: RUSTC_WRAPPER: sccache CC: sccache clang CXX: sccache clang++ + jobs: e2e: runs-on: ubuntu-latest + # Each 'include' entry will run as a separate job with its own parameters. + strategy: + fail-fast: false + matrix: + include: + - name: "Browser Tests" + script: "e2e/script.js" + port: 8787 + k6_browser_enabled: true + state_dir: "browser" + - name: "API Tests" + script: "e2e/test_claim_token_api.js" + port: 8787 + k6_browser_enabled: false + state_dir: "api" + - name: "Claim Token API CORS Tests" + script: "e2e/test_cors.js" + port: 8787 + k6_browser_enabled: false + state_dir: "cors" + + name: "E2E ${{ matrix.name }}" steps: - name: Setup sccache uses: mozilla-actions/sccache-action@v0.0.9 @@ -32,22 +55,37 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + ~/.npm + key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock', '**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-deps- + - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - - uses: sigoden/install-binary@v1 + - name: Install wasm-opt + uses: sigoden/install-binary@v1 with: repo: WebAssembly/binaryen name: wasm-opt - name: Install worker-build - run: cargo install --locked worker-build + run: cargo install --locked --force worker-build - - name: Set up k6 (with browser) + - name: Set up k6 uses: grafana/setup-k6-action@v1 with: - browser: true + browser: ${{ matrix.k6_browser_enabled }} - name: Set up secrets shell: bash @@ -56,7 +94,7 @@ jobs: echo "SECRET_MAINNET_WALLET=${{ secrets.TEST_MAINNET_PRIVATE_KEY_HEX }}" >> .dev.vars echo "SECRET_CALIBNET_USDFC_WALLET=${{ secrets.TEST_CALIBNET_USDFC_PRIVATE_KEY_HEX }}" >> .dev.vars - - name: Run website + - name: Build and run website run: | # These might or might not be the same as used for deployment. They are used strictly for testing purposes. # Note: those can't be put directly as environment variables in GH Actions (without a default value) due to @@ -69,11 +107,17 @@ jobs: corepack enable yarn --immutable yarn build - yarn start & - echo "waiting" - timeout 120 sh -c 'until nc -z $0 $1; do sleep 1; done' 127.0.0.1 8787 + # Use separate state directories to isolate Durable Object storage between parallel runs + yarn wrangler dev --port ${{ matrix.port }} --persist-to .wrangler-state-${{ matrix.state_dir }}-${{ github.run_id }} & + echo "waiting for server on port ${{ matrix.port }}" + timeout 120 sh -c 'until nc -z $0 $1; do sleep 1; done' 127.0.0.1 ${{ matrix.port }} + echo "TCP port check passed, now waiting for HTTP server to be ready..." + timeout 120 sh -c 'until curl -s --max-time 30 http://127.0.0.1:${{ matrix.port }} > /dev/null; do echo "Waiting for HTTP server (first request may take 60-90s)..."; sleep 5; done' + echo "Server is ready!" - - name: Run k6 E2E script + - name: Run k6 E2E Test uses: grafana/run-k6-action@v1 with: - path: 'e2e/script.js' + path: ${{ matrix.script }} + env: + API_URL: "http://127.0.0.1:${{ matrix.port }}" \ No newline at end of file diff --git a/docs/cors_testing_guide.md b/docs/cors_testing_guide.md new file mode 100644 index 00000000..0868987b --- /dev/null +++ b/docs/cors_testing_guide.md @@ -0,0 +1,258 @@ +# CORS Testing Guide + +## Key Terms + +**CORS (Cross-Origin Resource Sharing)**: Browser security mechanism that +controls whether web pages from one domain can access resources from another +domain. + +**Preflight Request**: An OPTIONS request browsers send before the actual +request to check if the cross-origin request is permitted. + +**Origin Header**: Identifies the domain making the request (e.g., +`https://app.example.com`). Automatically set by browsers, cannot be overridden +in JavaScript. + +**Access-Control-Allow-\* Headers**: Server response headers that tell browsers +which cross-origin requests are permitted. + +--- + +## What We're Testing + +### CORS Configuration for Public API Access + +Testing that the `/api/claim_token` endpoint properly supports cross-origin +requests from any domain: + +- **CalibnetFIL, CalibnetUSDFC, MainnetFIL** faucets accessible from external + websites +- **Browser-based DApps** can integrate with the faucet API +- **Third-party integrations** work without CORS errors + +### Current Configuration + +```rust +// src/lib.rs +let cors = CorsLayer::new() + .allow_origin(Any) // Accept requests from ANY origin + .allow_methods(Method::GET); // Only GET requests allowed +``` + +**Result**: `Access-Control-Allow-Origin: *` header on all responses + +**Note**: We don't set `allow_headers()` because GET requests only use "simple +headers" (like `Accept`, `User-Agent`) which don't require CORS preflight +permission. + +--- + +## How We Test in CI + +### Matrix Strategy with Isolated State + +```yaml +- name: "CORS Tests" + script: "e2e/test_cors.js" + port: 8788 # Separate port from Browser/API tests + k6_browser_enabled: false # No browser support needed + state_dir: "cors" # Isolated Durable Object state +``` + +Each test suite runs independently: + +```bash +yarn wrangler dev --port 8788 --persist-to .wrangler-state-cors-${github.run_id} +``` + +This prevents CORS test state from interfering with Browser or API tests. + +--- + +## Test Flow + +### 1. Preflight OPTIONS Request (5 checks) + +Validates that browsers can check permissions before sending actual requests: + +```javascript +// Request +OPTIONS /api/claim_token +Origin: https://external-example.com +Access-Control-Request-Method: GET + +// Validates +✓ Status is 200 or 204 +✓ Has Access-Control-Allow-Origin header +✓ Allows all origins (*) +✓ Has Access-Control-Allow-Methods header +✓ Allows GET method +``` + +### 2. Actual Cross-Origin GET Request (3 checks) + +Confirms the API properly responds to cross-origin requests: + +```javascript +// Request +GET /api/claim_token?faucet_info=CalibnetFIL&address=0x... +Origin: https://external-example.com + +// Validates +✓ Has Access-Control-Allow-Origin in response +✓ CORS header allows all origins (*) +✓ Response received (not blocked by CORS) +``` + +### 3. Same-Origin Request (2 checks) + +Verifies CORS headers are present even without explicit origin: + +```javascript +// Request (no Origin header) +GET /api/claim_token?faucet_info=CalibnetFIL&address=0x... + +// Validates +✓ Request succeeds (200 or 429) +✓ Has Access-Control-Allow-Origin header +``` + +### 4. Multiple Origins (9 checks) + +Tests that different domains can all access the API: + +```javascript +// Tests three different origins: +- https://app.example.com +- http://localhost:3000 +- https://wallet.filecoin.io + +// For each origin: +✓ Request succeeds +✓ CORS allows origin +✓ Response received +``` + +### 5. Security & Edge Cases (3 checks) + +Validates security headers and error handling: + +```javascript +// Security +✓ Access-Control-Allow-Credentials is NOT set (safer for public APIs) + +// Error responses +✓ CORS headers present even on 500/400 errors (critical for browser error handling) +``` + +**Total: 22 checks covering full CORS compliance** + +--- + +## Running Tests Locally + +```bash +# Start server +yarn wrangler dev --port 8787 + +# Run CORS tests +API_URL="http://127.0.0.1:8787" k6 run e2e/test_cors.js +``` + +### Expected Output (All Passing) + +``` +checks_succeeded...: 100.00% 22 out of 22 + +✓ Preflight: Status is 200 or 204 +✓ Preflight: Has Access-Control-Allow-Origin header +✓ Preflight: Allows all origins (*) +✓ Preflight: Has Access-Control-Allow-Methods +✓ Preflight: Allows GET method +✓ Actual Request: Has Access-Control-Allow-Origin in response +✓ Actual Request: CORS header allows all origins +✓ Actual Request: Response received (not blocked by CORS) +✓ Same-Origin: Request succeeds +✓ Same-Origin: Has Access-Control-Allow-Origin (even for same-origin) +✓ Multiple Origins: https://app.example.com - Request succeeds +✓ Multiple Origins: https://app.example.com - CORS allows origin +✓ Multiple Origins: https://app.example.com - Response received +✓ Multiple Origins: http://localhost:3000 - Request succeeds +✓ Multiple Origins: http://localhost:3000 - CORS allows origin +✓ Multiple Origins: http://localhost:3000 - Response received +✓ Multiple Origins: https://wallet.filecoin.io - Request succeeds +✓ Multiple Origins: https://wallet.filecoin.io - CORS allows origin +✓ Multiple Origins: https://wallet.filecoin.io - Response received +✓ Security: Has Access-Control-Allow-Origin +✓ Security: Access-Control-Allow-Credentials is not set +✓ Error Response: CORS headers present even on errors +``` + +**Note**: Total of 22 checks (5 preflight + 3 actual + 2 same-origin + 9 +multiple-origins + 3 security/edge cases) + +### Common Test Results + +**All CORS checks pass, but some "Request succeeds" fail:** + +- ✅ CORS is working correctly +- ⚠️ Server returning 500 errors (unrelated to CORS) +- Check server logs for the actual API error + +**CORS header checks failing:** + +- ❌ Configuration issue in `src/lib.rs` +- Verify `allow_origin(Any)` and `allow_headers(Any)` are set +- Confirm `CorsLayer` is applied to the router + +--- + +## Quick Manual Test + +### Browser DevTools (Simplest) + +1. Open browser console (F12) on any page +2. Run: + +```javascript +fetch( + "http://127.0.0.1:8787/api/claim_token?faucet_info=CalibnetFIL&address=f1pxxbe7he3c6vcw5as3gfvq33kprpmlufgtjgfdq", +) + .then((r) => console.log("✅ CORS works! Status:", r.status)) + .catch((err) => console.error("❌ CORS blocked:", err)); +``` + +### curl (For CI/Scripts) + +```bash +# Test preflight +curl -i -X OPTIONS \ + -H "Origin: https://example.com" \ + -H "Access-Control-Request-Method: GET" \ + http://127.0.0.1:8787/api/claim_token + +# Look for: Access-Control-Allow-Origin: * +``` + +--- + +## Why CORS Matters + +### Before CORS (Blocked) + +```javascript +// From https://my-dapp.com +fetch("https://faucet.chainsafe.io/api/claim_token?..."); +// ❌ Error: CORS policy: No 'Access-Control-Allow-Origin' header +``` + +### After CORS (Allowed) + +```javascript +// From https://my-dapp.com +fetch("https://faucet.chainsafe.io/api/claim_token?..."); +// ✅ Success: Response includes "Access-Control-Allow-Origin: *" +``` + +**Security**: Protected by rate limiting (60s cooldown, 2-drip wallet cap), not +CORS restrictions. diff --git a/docs/e2e_api_testing_guide.md b/docs/e2e_api_testing_guide.md new file mode 100644 index 00000000..3dc05406 --- /dev/null +++ b/docs/e2e_api_testing_guide.md @@ -0,0 +1,150 @@ +# Forest Explorer E2E Testing Guide + +## Key Terms + +**Durable Object (DO)**: A Cloudflare Worker with persistent state, used here to +manage rate limits and wallet caps across requests. + +**Filecoin Address Types**: Different formats representing the same underlying +wallet: + +- `t1`/`f1` (secp256k1): Traditional Filecoin addresses +- `t410`/`f410` (EVM): Ethereum-compatible addresses within Filecoin +- `eth` (0x): Standard Ethereum addresses +- `t0`/`f0` (ID): Numeric actor IDs corresponding to other address formats + +**Calibnet**: Filecoin's calibration testnet for development and testing. + +## What We're Testing + +### Browser vs API Split + +- **Browser Tests** (`e2e/script.js`): Full user journey through the web + interface. +- **API Tests** (`e2e/test_claim_token_api.js`): Direct validation of the + `/api/claim_token` endpoint. + +### Faucet API Core Functionality + +Testing the token claim API for different Filecoin networks: + +- **CalibnetFIL**: Calibration network FIL tokens. +- **CalibnetUSDFC**: Calibration network USDC tokens. +- **MainnetFIL**: Mainnet FIL tokens (read-only validation). + +### Rate Limiting & Wallet Cap Enforcement + +- **Per-faucet cooldown**: A 60-second minimum between requests to the same + faucet type. +- **Per-wallet limits**: A maximum of two successful claims per wallet address. +- **Independent faucets**: CalibnetFIL and CalibnetUSDFC have separate rate + limits. + +## How We Test in Parallel Without Interference + +### Matrix Strategy with Isolated State + +```yaml +strategy: + matrix: + include: + - name: "Browser Tests" + state_dir: "browser" + - name: "API Tests" + state_dir: "api" +``` + +Each test suite runs with isolated Durable Object storage: + +```bash +yarn wrangler dev --port ${matrix.port} --persist-to .wrangler-state-${state_dir}-${github.run_id} +``` + +This prevents the rate-limiting state from one test from affecting the other. + +## API Test Flow + +### 1. Connectivity Check + +Before running tests, `validateServerConnectivity()` confirms the server is +responsive. If this check fails, all subsequent tests are aborted. + +### 2. Input Validation Tests + +This section tests invalid parameters and malformed addresses using the +`INVALID_REQUESTS` scenarios: + +- Missing parameters (`faucet_info: null`, `address: null`). +- Invalid faucet types (`InvalidFaucet`). +- Malformed addresses (see the comprehensive edge cases in + `TEST_ADDRESSES.INVALID` within the configuration file). + +Each case asserts the specific HTTP status defined in `INVALID_REQUESTS`, +covering 500, 418, and 400 responses. + +### 3. Rate Limit Cooldown Tests (`RATE_LIMIT_TEST_COOLDOWN_CASES`) + +This sequence verifies that once a successful request is made, all subsequent +requests for any address type on that same faucet are rate-limited until the +60-second cooldown expires. + +**CalibnetFIL Sequence:** + +1. `CalibnetFIL (t1) - 1st SUCCESS` → 200 (starts 60s cooldown) +2. `CalibnetFIL (t410) - RATE LIMITED` → 429 (within cooldown) +3. `CalibnetFIL (eth) - RATE LIMITED` → 429 (within cooldown) +4. Additional address formats → All 429 (within cooldown) + +**CalibnetUSDFC Sequence (independent cooldown):** + +1. `CalibnetUSDFC (eth) - 1st SUCCESS` → 200 (starts separate 60s cooldown) +2. `CalibnetUSDFC (t410) - RATE LIMITED` → 429 (within USDFC cooldown) +3. Additional formats → All 429 (within USDFC cooldown) + +### 4. Wallet Cap Tests (`RATE_LIMIT_TEST_WALLET_CAP_CASES`) + +This sequence validates the 2-drip per-wallet limit by testing wallets that +reach their maximum claims, then verifying that all equivalent address formats +for the same wallet are also capped. The test sequences use a `waitBefore: 65` +second delay between claims to ensure the 60-second cooldown does not interfere +with wallet cap validation. + +**CalibnetFIL t1 wallet (already has 1 drip from cooldown tests):** + +1. Wait 65s → `2nd SUCCESS` (200, reaches cap) +2. Wait 65s → `3rd attempt WALLET CAPPED` (429, >1h retry time) + +**CalibnetFIL eth wallet (fresh for this faucet):** + +1. Wait 65s → `1st SUCCESS` (200) +2. Wait 65s → `2nd SUCCESS` (200, reaches cap) +3. Wait 65s → `3rd attempt WALLET CAPPED` (429, >1h retry time) +4. Test equivalent addresses (`t410`, `t0`, `ID`) → All 429 (same wallet) + +**CalibnetUSDFC eth wallet (already has 1 drip from cooldown tests):** + +1. Wait 65s → `2nd SUCCESS` (200, reaches cap) +2. Wait 65s → `3rd attempt WALLET CAPPED` (429, >1h retry time) +3. Test equivalent address (`t410`) → 429 (same wallet) + +### 5. Common Helper Functions + +**`makeClaimRequest(faucetInfo, address)`**: A centralized API call that handles +null parameters gracefully and provides consistent timeouts and headers. + +**`validateTransactionHash(txHash)`**: Validates that successful responses +return a proper Ethereum-formatted transaction hash (0x + 64 hex chars). + +**`runTestScenarios(scenarios, options)`**: Executes test arrays with optional +`waitBefore` delays and fail-fast logic that skips additional checks if the +primary status assertion fails. + +### Test Configuration Reference + +All test scenarios and edge cases are defined in +`test_claim_token_api_config.js`: + +- `INVALID_REQUESTS`: Parameter validation cases. +- `RATE_LIMIT_TEST_COOLDOWN_CASES`: 60-second cooldown enforcement. +- `RATE_LIMIT_TEST_WALLET_CAP_CASES`: 2-drip wallet limit enforcement. +- `TEST_ADDRESSES.INVALID`: A comprehensive corpus of malformed addresses. diff --git a/e2e/script.js b/e2e/script.js index 5ea2bf59..ce97ad05 100644 --- a/e2e/script.js +++ b/e2e/script.js @@ -18,7 +18,7 @@ export const options = { }, }; -const BASE_URL = "http://127.0.0.1:8787"; +const BASE_URL = __ENV.API_URL ||"http://127.0.0.1:8787"; // Check if the path is reachable async function checkPath(page, path) { diff --git a/e2e/test_claim_token_api.js b/e2e/test_claim_token_api.js new file mode 100644 index 00000000..e2488a44 --- /dev/null +++ b/e2e/test_claim_token_api.js @@ -0,0 +1,200 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { + API_CONFIG, + TEST_ADDRESSES, + STATUS_CODES, + TEST_SCENARIOS, + FaucetTypes +} from './test_claim_token_api_config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + 'checks': ['rate>=1.0'], + 'http_req_duration': ['p(95)<5000'], + }, +}; + +function validateTransactionHash(txHash) { + // Remove outer quotes if present + txHash = txHash.replace(/^"|"$/g, ''); + // Both CalibnetFIL and CalibnetUSDFC now return an Ethereum format: 0x + 64 hex chars = 66 total + return txHash.startsWith('0x') && txHash.length === 66; +} + +function runTestScenarios(scenarios, options = {}) { + const { + sleepBetween = 0, + allowWaiting = false, + additionalChecks = null + } = options; + + scenarios.forEach(testCase => { + if (allowWaiting && testCase.waitBefore && testCase.waitBefore > 0) { + console.log(` ...waiting ${testCase.waitBefore}s before next test...`); + sleep(testCase.waitBefore); + } + + const response = makeClaimRequest(testCase.faucet_info, testCase.address); + + // Primary status check - if this fails, skip additional validations + const statusCheckName = `${testCase.name}: Expected status ${testCase.expectedStatus}`; + const statusCheckResult = check(response, { + [statusCheckName]: (r) => r.status === testCase.expectedStatus + }); + + if (statusCheckResult) { + const additionalValidations = { + [`${testCase.name}: Valid transaction hash (if success)`]: (r) => + r.status !== STATUS_CODES.SUCCESS || validateTransactionHash(r.body.trim()) + }; + + // Add any test-specific additional checks + if (additionalChecks) { + Object.assign(additionalValidations, additionalChecks(testCase)); + } + + check(response, additionalValidations); + } else { + console.log(`❌ ${testCase.name}: Expected ${testCase.expectedStatus}, got ${response.status} - ${response.body}`); + console.log(`⏭️ Skipping additional validations for this test due to status check failure`); + } + + if (sleepBetween > 0) { + sleep(sleepBetween); + } + }); +} + +function makeClaimRequest(faucetInfo, address) { + let url = `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINT}`; + const params = []; + + if (faucetInfo !== null && faucetInfo !== undefined) { + params.push(`faucet_info=${encodeURIComponent(faucetInfo)}`); + } + if (address !== null && address !== undefined) { + params.push(`address=${encodeURIComponent(address)}`); + } + + if (params.length > 0) { + url += `?${params.join('&')}`; + } + + const response = http.get(url, { + timeout: API_CONFIG.REQUEST_TIMEOUT, + tags: { faucet_type: faucetInfo || 'unknown' } + }); + + const requestDescriptor = `${faucetInfo || 'unknown'} request to ${address ? address.substring(0, 10) + '...' : 'null'}`; + check(response, { + [`Network: ${requestDescriptor} - Response received`]: (r) => r.status !== 0, + [`Network: ${requestDescriptor} - No errors`]: (r) => !r.error, + [`Network: ${requestDescriptor} - Within timeout`]: (r) => r.timings.duration < API_CONFIG.MAX_RESPONSE_TIME + }); + + return response; +} + +// Test input validation +function testInputValidation() { + console.log('🧪 Starting input validation tests...'); + + // Test invalid addresses for both faucet types + const faucetTypes = [FaucetTypes.CalibnetFIL, FaucetTypes.CalibnetUSDFC]; + + faucetTypes.forEach((faucetType) => { + TEST_ADDRESSES.INVALID.forEach((invalidAddress, index) => { + const response = makeClaimRequest(faucetType, invalidAddress); + const testName = `${faucetType} - Invalid address "${invalidAddress}"`; + + check(response, { + [`${testName}: Proper rejection (400)`]: (r) => r.status === STATUS_CODES.BAD_REQUEST, + [`${testName}: Error message contains 'invalid'`]: (r) => + r.body && r.body.toLowerCase().includes("invalid") + }); + }); + }); + + // Test all other invalid request scenarios (missing parameters, mainnet blocking, etc.) + TEST_SCENARIOS.INVALID_REQUESTS.forEach((testCase) => { + const response = makeClaimRequest(testCase.faucet_info, testCase.address); + + check(response, { + [`${testCase.name}: Expected status ${testCase.expectedStatus}`]: (r) => + r.status === testCase.expectedStatus, + [`${testCase.name}: Contains expected error "${testCase.expectedErrorContains}"`]: (r) => + r.body && r.body.toLowerCase().includes(testCase.expectedErrorContains.toLowerCase()) + }); + }); + + console.log('✅ Input validation tests completed'); +} + +function testRateLimiting() { + console.log('\n📊 Testing Faucet-Specific Rate Limiting...'); + console.log('📝 Pattern: One success per faucet → All addresses for that faucet get rate limited'); + + runTestScenarios(TEST_SCENARIOS.RATE_LIMIT_TEST_COOLDOWN_CASES, { + sleepBetween: 0.5 + }); +} + +function testWalletCap() { + console.log('\n💰 Testing Wallet Cap Limits (2 drips per wallet)...'); + + const walletCapChecks = (testCase) => ({ + [`${testCase.name}: Wallet cap retry time >1h (if capped)`]: (r) => { + if (!testCase.walletCapErrorResponse || r.status !== STATUS_CODES.TOO_MANY_REQUESTS) { + return true; + } + const retrySeconds = parseInt((r.body.match(/(\d+)/) || [null, 0])[1]); + return retrySeconds > 3600; + } + }); + + runTestScenarios(TEST_SCENARIOS.RATE_LIMIT_TEST_WALLET_CAP_CASES, { + allowWaiting: true, + additionalChecks: walletCapChecks + }); +} + +function validateServerConnectivity() { + console.log('🔗 Checking server connectivity...'); + + let healthResponse; + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + healthResponse = http.get(API_CONFIG.BASE_URL, { timeout: '10s' }); + + if (healthResponse.status !== 0 && !healthResponse.error) { + console.log('✅ Server connectivity confirmed'); + return true; + } + + if (attempts < maxAttempts) { + console.log(`⏳ Server not ready (attempt ${attempts}/${maxAttempts}), waiting 5s...`); + sleep(5); + } + } + + console.error(`❌ Server not reachable after ${maxAttempts} attempts: ${healthResponse.error || 'Connection failed'}`); + return false; +} + +export default function () { + if (!validateServerConnectivity()) { + console.error('❌ Aborting tests: Server not responsive'); + return; + } + + testInputValidation(); + testRateLimiting(); + testWalletCap(); + console.log('\n✅ All tests passed successfully!'); +} \ No newline at end of file diff --git a/e2e/test_claim_token_api_config.js b/e2e/test_claim_token_api_config.js new file mode 100644 index 00000000..831d90e4 --- /dev/null +++ b/e2e/test_claim_token_api_config.js @@ -0,0 +1,338 @@ +// API Test Configuration +export const API_CONFIG = { + // Base URL - can be overridden by API_URL environment variable + BASE_URL: __ENV.API_URL || 'http://127.0.0.1:8787', + ENDPOINT: '/api/claim_token', + + // Test timeouts + REQUEST_TIMEOUT: '30s', + MAX_RESPONSE_TIME: 5000, // 5 seconds + CONNECTION_TIMEOUT: '10s', // For connectivity checks + + FAUCET_COOLDOWN_BUFFER_SECONDS: 65, +}; + +export const TEST_ADDRESSES = { + // Primary test addresses for API tests + F1_FORMAT_ADDRESS: 'f1pxxbe7he3c6vcw5as3gfvq33kprpmlufgtjgfdq', + T1_FORMAT_ADDRESS: 't1pxxbe7he3c6vcw5as3gfvq33kprpmlufgtjgfdq', + T410_ADDRESS: 't410fv2oexfiizeuzm3xtoie3gnxfpfwwglg4q3dgxki', + ETH_FORMAT_ADDRESS: '0xAe9C4b9508c929966ef37209b336E5796D632CDc', + T0_ADDRESS: 't0163355', + ETH_ID_CORRESPONDING: '0xff00000000000000000000000000000000027e1b', + + INVALID: [ + // Basic invalid cases + 'invalidaddress', + '0xinvalid', + 't1invalid', + 'f1invalid', + '', + '0x123', + 'randomstring', + '0xABC', + 't1abc', + 'f1xyz', + + // Edge cases: Malformed Ethereum addresses + '0x', + '0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef12345', // 63 chars (too short) + '0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef123456789', // 65 chars (too long) + '0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef12345G', // Invalid hex char 'G' + '0X123456789ABCDEF123456789ABCDEF123456789ABCDEF123456789ABCDEF123456', // Uppercase 0X prefix + '123456789abcdef123456789abcdef123456789abcdef123456789abcdef123456', // Missing 0x prefix + + // Edge cases: Malformed Filecoin addresses with invalid checksums + 'f1invalidchecksumaddresshere1234567890', + 't1invalidchecksumaddresshere1234567890', + 'f3invalidchecksumaddresshere1234567890abcdef', + 't3invalidchecksumaddresshere1234567890abcdef', + 'f410invalidchecksumaddresshere1234567890abcdef123456', + 't410invalidchecksumaddresshere1234567890abcdef123456', + + // Edge cases: Invalid Filecoin address formats + 'f5unsupportedprotocol', + 't999unsupported', + 'f1', + 't1', + 'f1!@#$%^&*()', + 't1!@#$%^&*()', + 'f0', + 'm1validlengthbutinvalidnetwork' + ] +}; + +export const FaucetTypes = { + CalibnetFIL: 'CalibnetFIL', + CalibnetUSDFC: 'CalibnetUSDFC', + MainnetFIL: 'MainnetFIL', + InvalidFaucet: 'InvalidFaucet' +}; + +export const STATUS_CODES = { + SUCCESS: 200, + BAD_REQUEST: 400, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, + IM_A_TEAPOT: 418 +}; + + +export const TEST_SCENARIOS = { + // Invalid request test cases + INVALID_REQUESTS: [ + // Missing parameter tests + { + name: 'Missing both parameters', + faucet_info: null, + address: null, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'missing' + }, + { + name: 'Missing faucet_info parameter', + faucet_info: null, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'missing' + }, + { + name: 'Missing address parameter CalibnetFIL', + faucet_info: FaucetTypes.CalibnetFIL, + address: null, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'missing' + }, + { + name: 'Missing address parameter CalibnetUSDFC', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: null, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'missing' + }, + { + name: 'MainnetFIL request (should be blocked)', + faucet_info: FaucetTypes.MainnetFIL, + address: TEST_ADDRESSES.F1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.IM_A_TEAPOT, + expectedErrorContains: 'teapot' + }, + // Invalid faucet type tests + { + name: 'Invalid faucet type', + faucet_info: FaucetTypes.InvalidFaucet, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'unknown variant' + }, + { + name: 'Empty faucet_info parameter', + faucet_info: '', + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'unknown variant' + }, + { + name: 'Invalid address format for CalibnetUSDFC', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.INTERNAL_SERVER_ERROR, + expectedErrorContains: 'invalid address' + } + ], + + RATE_LIMIT_TEST_COOLDOWN_CASES: [ + // === CalibnetFIL Tests: One success → All addresses rate limited === + { + name: 'CalibnetFIL (t1) - 1st SUCCESS (starts 60s cooldown for CalibnetFIL)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS + }, + { + name: 'CalibnetFIL (t410) - RATE LIMITED (within CalibnetFIL cooldown)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T410_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + { + name: 'CalibnetFIL (eth) - RATE LIMITED (within CalibnetFIL cooldown)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + { + name: 'CalibnetFIL (t0) - RATE LIMITED (within CalibnetFIL cooldown)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T0_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + { + name: 'CalibnetFIL (ID) - RATE LIMITED (within CalibnetFIL cooldown)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + + // === CalibnetUSDFC Tests: Independent cooldown from CalibnetFIL === + { + name: 'CalibnetUSDFC (eth) - 1st SUCCESS (starts 60s cooldown for CalibnetUSDFC)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS + }, + { + name: 'CalibnetUSDFC (t410) - RATE LIMITED (within CalibnetUSDFC cooldown)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.T410_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + { + name: 'CalibnetUSDFC (t0) - RATE LIMITED (within CalibnetUSDFC cooldown)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.T0_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + }, + // CalibnetUSDFC doesn't support the t1 format address + { + name: 'CalibnetUSDFC (ID) - RATE LIMITED (within CalibnetUSDFC cooldown)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS + } + ], + + RATE_LIMIT_TEST_WALLET_CAP_CASES: [ + // === CalibnetFIL t1 Wallet (already has 1 transaction in RATE_LIMIT_TEST_COOLDOWN_CASES) === + { + name: 'CalibnetFIL (t1) - 2nd SUCCESS (reaches cap)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS, + waitBefore: 65, // Wait for cooldown from the main rate-limit tests to expire + walletCapErrorResponse: false, + }, + { + name: 'CalibnetFIL (t1) - 3rd attempt (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T1_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 65, // Wait for cooldown from its own 2nd transaction + walletCapErrorResponse: true, + }, + + // === CalibnetFIL eth/t410 Wallet (fresh wallet for this faucet) === + { + name: 'CalibnetFIL (eth) - 1st SUCCESS', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS, + waitBefore: 65, // Wait for cooldown from the previous test group + walletCapErrorResponse: false, + }, + { + name: 'CalibnetFIL (eth) - 2nd SUCCESS (reaches cap)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS, + waitBefore: 65, // Wait for its own cooldown + walletCapErrorResponse: false, + }, + { + name: 'CalibnetFIL (eth) - 3rd attempt (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 65, // Wait for its own cooldown + walletCapErrorResponse: true, + }, + { + name: 'CalibnetFIL (t410) - check equivalence (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T410_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 0, // No wait needed, should be capped, already from the previous step + walletCapErrorResponse: true, + }, + { + name: 'CalibnetFIL (t0) - check equivalence (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.T0_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 0, // No wait needed, should be capped, already from the previous step + walletCapErrorResponse: true, + }, + { + name: 'CalibnetFIL (ID) - check equivalence (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetFIL, + address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 0, // No wait needed, should be capped, already from the previous step + walletCapErrorResponse: true, + }, + + // === CalibnetUSDFC eth/t410 Wallet (already has 1 transaction in RATE_LIMIT_TEST_COOLDOWN_CASES) === + { + name: 'CalibnetUSDFC (eth) - 2nd SUCCESS (reaches cap)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.SUCCESS, + waitBefore: 65, // Wait for cooldown from the previous test group to expire + walletCapErrorResponse: false, + }, + { + name: 'CalibnetUSDFC (eth) - 3rd attempt (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.ETH_FORMAT_ADDRESS, + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 65, // Wait for cooldown from its own 2nd transaction + walletCapErrorResponse: true, + }, + { + name: 'CalibnetUSDFC (t410) - check equivalence (WALLET CAPPED)', + faucet_info: FaucetTypes.CalibnetUSDFC, + address: TEST_ADDRESSES.T410_ADDRESS, // This is the same wallet as the ETH address + expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + waitBefore: 0, // No wait needed, should be capped, already from the previous step + walletCapErrorResponse: true, + }, + + // TODO(forest-explorer): https://github.com/ChainSafe/forest-explorer/issues/335 + // Token sent to the t0 and it's eth mapping address 0x + // are not accessible. Hence commenting out the following + // test cases. + // === CalibnetUSDFC ID Wallet (fresh wallet, 0 transactions) === + // { + // name: 'CalibnetUSDFC (ID) - 1st SUCCESS (fresh wallet)', + // faucet_info: FaucetTypes.CalibnetUSDFC, + // address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + // expectedStatus: STATUS_CODES.SUCCESS, + // waitBefore: 65, // Wait for cooldown from the previous test group to expire + // walletCapErrorResponse: false, + // }, + // { + // name: 'CalibnetUSDFC (ID) - 2nd SUCCESS (reaches cap)', + // faucet_info: FaucetTypes.CalibnetUSDFC, + // address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + // expectedStatus: STATUS_CODES.SUCCESS, + // waitBefore: 65, // Wait for cooldown from its own 1st transaction + // walletCapErrorResponse: false, + // }, + // { + // name: 'CalibnetUSDFC (ID) - 3rd attempt (WALLET CAPPED)', + // faucet_info: FaucetTypes.CalibnetUSDFC, + // address: TEST_ADDRESSES.ETH_ID_CORRESPONDING, + // expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + // waitBefore: 65, // Wait for cooldown from its own 2nd transaction + // walletCapErrorResponse: true, + // }, + // { + // name: 'CalibnetUSDFC (t0) - check equivalence (WALLET CAPPED)', + // faucet_info: FaucetTypes.CalibnetUSDFC, + // address: TEST_ADDRESSES.T0_ADDRESS, // This is the same wallet as the ID address + // expectedStatus: STATUS_CODES.TOO_MANY_REQUESTS, + // waitBefore: 0, // No wait needed, should be capped already + // walletCapErrorResponse: true, + // }, + ] +}; diff --git a/e2e/test_cors.js b/e2e/test_cors.js new file mode 100644 index 00000000..bcf09c08 --- /dev/null +++ b/e2e/test_cors.js @@ -0,0 +1,117 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { FaucetTypes, TEST_ADDRESSES } from "./test_claim_token_api_config.js"; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + 'checks': ['rate>=1.0'], + }, +}; + +const API_URL = __ENV.API_URL || 'http://127.0.0.1:8787'; +const CLAIM_TOKEN_ENDPOINT = `${API_URL}/api/claim_token`; + +/** + * Test CORS Configuration for the Claim Token API + * + * This test validates that the API properly handles Cross-Origin Resource Sharing (CORS) + * by checking the appropriate headers in both preflight (OPTIONS) and actual requests. + */ +export default function () { + // Test 1: Preflight OPTIONS Request + const preflightResponse = http.options(CLAIM_TOKEN_ENDPOINT, null, { + headers: { + 'Origin': 'https://external-example.com', + 'Access-Control-Request-Method': 'GET', + }, + }); + + check(preflightResponse, { + 'Preflight: Status is 200 or 204': (r) => r.status === 200 || r.status === 204, + 'Preflight: Has Access-Control-Allow-Origin header': (r) => + r.headers['Access-Control-Allow-Origin'] !== undefined, + 'Preflight: Allows all origins (*)': (r) => + r.headers['Access-Control-Allow-Origin'] === '*', + 'Preflight: Has Access-Control-Allow-Methods': (r) => + r.headers['Access-Control-Allow-Methods'] !== undefined, + 'Preflight: Allows GET method': (r) => { + const methods = r.headers['Access-Control-Allow-Methods'] || ''; + return methods.toUpperCase().includes('GET'); + }, + }); + + // Test 2: Actual GET Request with Origin Header + const url = `${CLAIM_TOKEN_ENDPOINT}?faucet_info=${FaucetTypes.CalibnetFIL}&address=${TEST_ADDRESSES.ETH_FORMAT_ADDRESS}`; + + const actualResponse = http.get(url, { + headers: { + 'Origin': 'https://external-example.com', + }, + }); + + check(actualResponse, { + 'Actual Request: Has Access-Control-Allow-Origin in response': (r) => + r.headers['Access-Control-Allow-Origin'] !== undefined, + 'Actual Request: CORS header allows all origins': (r) => + r.headers['Access-Control-Allow-Origin'] === '*', + 'Actual Request: Response received (not blocked by CORS)': (r) => + r.status !== 0 && r.body !== '', + }); + + // Test 3: Request WITHOUT Origin (same-origin simulation) + const sameOriginResponse = http.get(url); + + check(sameOriginResponse, { + 'Same-Origin: Request succeeds': (r) => r.status === 200 || r.status === 429, + 'Same-Origin: Has Access-Control-Allow-Origin (even for same-origin)': (r) => + r.headers['Access-Control-Allow-Origin'] !== undefined, + }); + + // Test 4: Multiple Origins Test + const origins = [ + 'https://app.example.com', + 'http://localhost:3000', + 'https://wallet.filecoin.io', + ]; + + origins.forEach(origin => { + const response = http.get(url, { + headers: { 'Origin': origin }, + }); + + check(response, { + [`Multiple Origins: ${origin} - Request succeeds`]: (r) => + r.status === 200 || r.status === 429, + [`Multiple Origins: ${origin} - CORS allows origin`]: (r) => + r.headers['Access-Control-Allow-Origin'] === '*', + [`Multiple Origins: ${origin} - Response received`]: (r) => + r.status !== 0 && r.body !== '', + }); + }); + + // Test 5: Check for Security Headers + const securityResponse = http.get(url, { + headers: { 'Origin': 'https://external-example.com' }, + }); + + check(securityResponse, { + 'Security: Has Access-Control-Allow-Origin': (r) => + r.headers['Access-Control-Allow-Origin'] !== undefined, + 'Security: Access-Control-Allow-Credentials is not set (safer for public APIs)': (r) => + r.headers['Access-Control-Allow-Credentials'] === undefined || + r.headers['Access-Control-Allow-Credentials'] === 'false', + }); + + // Test 6: Edge Case - CORS headers should be present even on error responses + // This is critical - even 500 errors should have CORS headers, so browser apps can read the error + const errorResponse = http.get(`${CLAIM_TOKEN_ENDPOINT}?invalid=params`, { + headers: { 'Origin': 'https://external-example.com' }, + }); + + check(errorResponse, { + 'Error Response: CORS headers present even on errors': (r) => + r.headers['Access-Control-Allow-Origin'] !== undefined, + }); +}