Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,21 +208,92 @@ 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
run: |
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
Expand Down
153 changes: 73 additions & 80 deletions backend/load-test.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading