diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f610b66..b0baa5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,13 +208,83 @@ jobs: name: python-audit-report path: python-service/audit-report.txt + verify-perf: + name: k6 performance test — verify endpoint + runs-on: ubuntu-latest + needs: backend + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + cache-dependency-path: backend/package-lock.json + + - name: Install backend dependencies + working-directory: backend + run: npm ci + + - name: Start backend + working-directory: backend + run: node src/app.js & + env: + NODE_ENV: test + PORT: 4000 + # Minimal env so config validation passes + STELLAR_NETWORK: testnet + HORIZON_URL: https://horizon-testnet.stellar.org + SOROBAN_RPC_URL: https://soroban-testnet.stellar.org + STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 + VACCINATIONS_CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM + ADMIN_SECRET_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ADMIN_PUBLIC_KEY: GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF + SEP10_SERVER_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB + ISSUER_SECRET_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC + JWT_SECRET: ci-test-secret + + - name: Wait for backend + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:4000/health && exit 0 + sleep 2 + done + echo "Backend did not start" && exit 1 + + - name: Install k6 + run: | + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \ + --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \ + | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update -qq && sudo apt-get install -y k6 + + - name: Run k6 load test + working-directory: backend + run: | + k6 run \ + -e BASE_URL=http://localhost:4000 \ + --out json=load-test-results.json \ + load-test.js + + - name: Upload load test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: verify-perf-results-${{ github.run_id }} + path: | + backend/load-test-results.json + backend/load-test-summary.json + retention-days: 90 + # Gate job: all test jobs must pass before a PR can be merged. # Configure this job name ("All tests passed") as a required status check # in Settings → Branches → Branch protection rules for `main`. all-tests: name: All tests passed runs-on: ubuntu-latest - needs: [contract, backend, frontend, python] + needs: [contract, backend, frontend, python, verify-perf] if: always() steps: - name: Check all test jobs succeeded @@ -222,7 +292,8 @@ jobs: if [[ "${{ needs.contract.result }}" != "success" || \ "${{ needs.backend.result }}" != "success" || \ "${{ needs.frontend.result }}" != "success" || \ - "${{ needs.python.result }}" != "success" ]]; then + "${{ needs.python.result }}" != "success" || \ + "${{ needs.verify-perf.result }}" != "success" ]]; then echo "One or more test jobs failed." exit 1 fi diff --git a/backend/load-test.js b/backend/load-test.js index 16285e6..cb3f12c 100644 --- a/backend/load-test.js +++ b/backend/load-test.js @@ -1,102 +1,95 @@ +/** + * k6 performance test for GET /v1/verify/public/:wallet + * + * Scenarios: + * cached — 100 VUs hit the same wallet repeatedly (cache hit after first request) + * uncached — 100 VUs each hit a unique wallet (cache miss every time) + * + * Acceptance criteria (issue #350): + * cached p95 < 500 ms + * uncached p95 < 2000 ms + * error rate < 1 % + */ import http from 'k6/http'; -import { check, group, sleep } from 'k6'; -import { Rate, Trend, Counter, Gauge } from 'k6/metrics'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js'; -// Custom metrics const errorRate = new Rate('errors'); -const responseTime = new Trend('response_time'); -const successCount = new Counter('success_count'); -const p95ResponseTime = new Trend('p95_response_time'); -// Configuration const BASE_URL = __ENV.BASE_URL || 'http://localhost:4000'; -const DURATION = __ENV.DURATION || '60s'; -const VUS = __ENV.VUS || 100; -const RAMP_UP = __ENV.RAMP_UP || '10s'; -// Test wallets (valid Stellar addresses for testing) -const TEST_WALLETS = [ - 'GBRPYHIL2CI3WHZDTOOQFC6EB4RBMPUTKXWDAUUJQHTITE4K3B6Rrytm', - 'GBBD47UZQ5UARKHTX4V2HA2KYRMF2ZSXBK6D5I4VRVHBT5RMJJWUBTQ', - 'GCZST3XVCDTUJ76ZAV2HA2KYRMF2ZSXBK6D5I4VRVHBT5RMJJWUBTQ', - 'GDZST3XVCDTUJ76ZAV2HA2KYRMF2ZSXBK6D5I4VRVHBT5RMJJWUBTQ', - 'GEZST3XVCDTUJ76ZAV2HA2KYRMF2ZSXBK6D5I4VRVHBT5RMJJWUBTQ', -]; +// One fixed wallet for the cached scenario (all VUs share it → cache warms up fast) +const CACHED_WALLET = 'GBRPYHIL2CI3WHZDTOOQFC6EB4RBMPUTKXWDAUUJQHTITE4K3B6RYTTM'; export const options = { - stages: [ - { duration: RAMP_UP, target: VUS }, - { duration: DURATION, target: VUS }, - { duration: '10s', target: 0 }, - ], + scenarios: { + cached: { + executor: 'constant-vus', + vus: 100, + duration: '30s', + env: { SCENARIO: 'cached' }, + tags: { scenario: 'cached' }, + }, + uncached: { + executor: 'constant-vus', + vus: 100, + duration: '30s', + startTime: '35s', // run after cached scenario finishes + env: { SCENARIO: 'uncached' }, + tags: { scenario: 'uncached' }, + }, + }, thresholds: { - 'http_req_duration': ['p(95)<500', 'p(99)<1000'], - 'errors': ['rate<0.01'], + // cached scenario: p95 < 500 ms + 'http_req_duration{scenario:cached}': ['p(95)<500'], + // uncached scenario: p95 < 2000 ms + 'http_req_duration{scenario:uncached}': ['p(95)<2000'], + // overall error rate < 1 % + errors: ['rate<0.01'], }, }; export default function () { - group('Verify Vaccination Endpoint', () => { - const wallet = TEST_WALLETS[Math.floor(Math.random() * TEST_WALLETS.length)]; - const url = `${BASE_URL}/verify/${wallet}`; - - const params = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${__ENV.JWT_TOKEN || 'test-token'}`, - }, - timeout: '10s', - }; - - const response = http.get(url, params); - - // Record response time - responseTime.add(response.timings.duration); - p95ResponseTime.add(response.timings.duration); + const scenario = __ENV.SCENARIO; + + // For uncached, each VU uses a unique wallet derived from its ID + iteration + // so the cache never has a warm entry for it. + const wallet = + scenario === 'uncached' + ? uniqueWallet(__VU, __ITER) + : CACHED_WALLET; + + const res = http.get(`${BASE_URL}/v1/verify/public/${wallet}`, { + timeout: '10s', + tags: { scenario }, + }); - // Check response - const success = check(response, { - 'status is 200 or 401': (r) => r.status === 200 || r.status === 401, - 'response time < 500ms': (r) => r.timings.duration < 500, - 'response has wallet field': (r) => r.body.includes('wallet'), - }); + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'has wallet field': (r) => r.body && r.body.includes('"wallet"'), + }); - if (success) { - successCount.add(1); - } else { - errorRate.add(1); - } + errorRate.add(!ok); + sleep(0.1); +} - // Small delay between requests - sleep(0.1); - }); +/** + * Generate a syntactically valid-looking Stellar public key that is unique per + * VU+iteration so the server cache never has a warm entry for it. + * The key doesn't need to exist on-chain — the endpoint returns verified:false + * for unknown wallets, which is still a 200 and exercises the full code path. + */ +function uniqueWallet(vu, iter) { + const base = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const suffix = String(vu * 10000 + iter).padStart(6, '0'); + // Replace the last characters to keep total length at 56 + return base.slice(0, 50) + suffix; } export function handleSummary(data) { return { - 'stdout': textSummary(data, { indent: ' ', enableColors: true }), - 'summary.json': JSON.stringify(data), + stdout: textSummary(data, { indent: ' ', enableColors: true }), + 'load-test-summary.json': JSON.stringify(data, null, 2), }; } - -function textSummary(data, options) { - const indent = options.indent || ''; - const colors = options.enableColors || false; - - let summary = '\n=== Load Test Summary ===\n'; - summary += `Total Requests: ${data.metrics.http_reqs?.value || 0}\n`; - summary += `Errors: ${data.metrics.errors?.value || 0}\n`; - summary += `Success Rate: ${((1 - (data.metrics.errors?.value || 0) / (data.metrics.http_reqs?.value || 1)) * 100).toFixed(2)}%\n`; - - if (data.metrics.http_req_duration) { - const duration = data.metrics.http_req_duration; - summary += `\nResponse Times:\n`; - summary += ` Min: ${duration.stats?.min?.toFixed(2) || 'N/A'}ms\n`; - summary += ` Max: ${duration.stats?.max?.toFixed(2) || 'N/A'}ms\n`; - summary += ` Avg: ${duration.stats?.avg?.toFixed(2) || 'N/A'}ms\n`; - summary += ` P95: ${duration.stats?.p(95)?.toFixed(2) || 'N/A'}ms\n`; - summary += ` P99: ${duration.stats?.p(99)?.toFixed(2) || 'N/A'}ms\n`; - } - - return summary; -}