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
30 changes: 30 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,33 @@ jobs:
else
echo "✅ All snapshots match"
fi

# #1320: coverage gate. Runs the whole backend suite with Node's built-in coverage (needs Node 22
# for the lcov reporter) and fails when line, branch or function coverage drops below the floors in
# backend/coverage-thresholds.json. Test failures are reported by the steps above, not here.
backend-coverage:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Backend coverage gate
run: npm run test:coverage --workspace=backend

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: backend/coverage/lcov.info
if-no-files-found: ignore
9 changes: 9 additions & 0 deletions .github/workflows/contracts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ jobs:
node --test scripts/check-contract-events.test.mjs
node scripts/check-contract-events.mjs

# #1317: SMT proofs (Z3) of the reward-accounting invariant
# TOTAL_SUPPLY == sum of balances for credit, batch_credit, claim and
# admin_transfer, plus negative controls. See docs/FORMAL_VERIFICATION.md.
- name: Verify reward accounting invariants (Z3)
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq z3
./scripts/verify-reward-invariants.sh

- name: Run contract tests
run: cargo test --workspace

Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,40 @@ jobs:
else
echo "⚠️ No test report generated for ${{ matrix.browser }}" >> $GITHUB_STEP_SUMMARY
fi

# #1319: hermetic browser flow — admin creates a campaign, the campaign page loads and a
# participant claims rewards. Builds its own bundle with an admin wallet and rewards contract
# configured; the REST API, Horizon, Soroban RPC and the wallet extension are mocked in the test.
campaign-to-claim-e2e:
name: Campaign creation to reward claim (Playwright)
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

- name: Run campaign-to-claim flow
run: npm run test:e2e:flow
working-directory: frontend

- name: Upload flow results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-flow-results
path: |
frontend/playwright-report
frontend/test-results
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ Thumbs.db
**/playwright-report-visual/
**/test-results/
backend/build/
**/dist-e2e/
backend/coverage/
21 changes: 21 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,27 @@ Requires API key if `TRIVELA_API_KEY` is set.
- `action` – Filter by action (`create`, `update`, `delete`)
- `page`, `limit`, `offset` – Same pagination params as other list endpoints

Every entry records **who** (`actor`), **what** (`action`, `entity`, `entityId`, `diff`), **when**
(`timestamp`) and **from where** (`ipAddress`, the request's client IP, `null` for entries written
before the column existed or without a request). The `audit_logs` table is **append-only**: database
triggers reject any `UPDATE` or `DELETE`, and each row is part of a SHA-256 hash chain (the IP is
included in the hash when present) that `GET /api/v1/admin/audit/verify` re-checks, so a rewritten or
removed entry is detected even by someone who removes the triggers. Behind a reverse proxy, set
Express's `trust proxy` so `req.ip` is the real client address rather than the proxy's.

## Test coverage (issue #1320)

```bash
npm run test:coverage --workspace=backend # needs Node >= 22
```

This runs the whole backend suite with Node's built-in coverage, writes `backend/coverage/lcov.info`
and fails when line, branch or function coverage is below the floors in
`backend/coverage-thresholds.json` (the `backend-coverage` job in `.github/workflows/backend-ci.yml`
runs it on every pull request). The target is **85 %**; the floors are a ratchet that sits just under
the current level — when a change raises coverage, raise the floors with it, and never lower them.
The gate judges coverage only; failing tests are reported by the regular test steps.

## PostgreSQL (issue #284)

The backend ships with two repository implementations behind a single DAL factory. `better-sqlite3`
Expand Down
6 changes: 6 additions & 0 deletions backend/coverage-thresholds.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"target": 85,
"lines": 60,
"branches": 80,
"functions": 62
}
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"docs:build": "node scripts/build-redoc.js",
"openapi:lint": "redocly lint openapi.yaml",
"security:xss": "node scripts/security-audit.js",
"security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'"
"security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'",
"test:coverage": "node scripts/check-coverage.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1057.0",
Expand Down
106 changes: 106 additions & 0 deletions backend/scripts/check-coverage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env node
// Backend coverage gate (#1320).
//
// Runs the backend test suite with Node's built-in coverage, writes an lcov report to
// backend/coverage/lcov.info, prints a summary and fails when line, branch or function coverage
// falls below the floors in backend/coverage-thresholds.json.
//
// Why a script instead of `--test-coverage-lines=N`: the gate must judge *coverage*, independent
// of whether every test passes (test failures are reported by the regular test steps), and it
// reports all three metrics at once. Needs Node >= 22 (lcov test reporter).
//
// npm run test:coverage --workspace=backend # run tests + gate
// node scripts/check-coverage.mjs --from-lcov coverage/lcov.info # gate an existing report
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const lcovPath = join(root, 'coverage', 'lcov.info');
const thresholds = JSON.parse(readFileSync(join(root, 'coverage-thresholds.json'), 'utf8'));

/** Sum the LF/LH, BRF/BRH and FNF/FNH totals of an lcov report. */
export function summariseLcov(text) {
const totals = { lines: [0, 0], branches: [0, 0], functions: [0, 0] };
const keys = {
LF: ['lines', 0],
LH: ['lines', 1],
BRF: ['branches', 0],
BRH: ['branches', 1],
FNF: ['functions', 0],
FNH: ['functions', 1],
};
for (const line of text.split('\n')) {
const [tag, value] = line.split(':');
const target = keys[tag];
if (target) totals[target[0]][target[1]] += Number(value) || 0;
}
const pct = ([found, hit]) => (found === 0 ? 100 : (hit / found) * 100);
return {
lines: pct(totals.lines),
branches: pct(totals.branches),
functions: pct(totals.functions),
};
}

function findTests(dir, out = []) {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'coverage') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) findTests(full, out);
else if (entry.endsWith('.test.js')) out.push(relative(root, full));
}
return out;
}

function runTests() {
mkdirSync(dirname(lcovPath), { recursive: true });
const result = spawnSync(
process.execPath,
[
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=**/*.test.js',
'--test-coverage-exclude=**/node_modules/**',
'--test-timeout=20000',
'--test-concurrency=8',
'--test-reporter=lcov',
`--test-reporter-destination=${lcovPath}`,
'--test-reporter=dot',
'--test-reporter-destination=stdout',
...findTests(join(root, 'src')),
],
{ cwd: root, stdio: 'inherit' },
);
if (result.error) throw result.error;
// A non-zero exit here only means some tests failed; those are enforced by the test steps.
}

const fromIndex = process.argv.indexOf('--from-lcov');
const source = fromIndex > -1 ? resolve(process.argv[fromIndex + 1]) : lcovPath;
if (fromIndex === -1) runTests();

if (!existsSync(source)) {
console.error(`No lcov report at ${source}; the test run produced no coverage data.`);
process.exit(2);
}

const actual = summariseLcov(readFileSync(source, 'utf8'));
let failed = false;
console.log('\nBackend coverage');
for (const metric of ['lines', 'branches', 'functions']) {
const floor = thresholds[metric];
const ok = actual[metric] >= floor;
failed ||= !ok;
console.log(
` ${ok ? 'PASS' : 'FAIL'} ${metric.padEnd(9)} ${actual[metric].toFixed(2)}% (minimum ${floor}%, target ${thresholds.target}%)`,
);
}
if (failed) {
console.error(
'\nCoverage is below the floor. Add tests for the code you changed; do not lower coverage-thresholds.json.',
);
process.exit(1);
}
console.log('\nFloors are ratchets: when coverage rises, raise them in coverage-thresholds.json.');
70 changes: 70 additions & 0 deletions backend/src/config/envValidation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateBackendEnv } from './envValidation.js';

test('an empty environment is valid', () => {
assert.doesNotThrow(() => validateBackendEnv({}));
});

test('a well-formed configuration is valid', () => {
assert.doesNotThrow(() =>
validateBackendEnv({
PORT: '3001',
RATE_LIMIT_WINDOW_MS: '60000',
RATE_LIMIT_MAX_REQUESTS: '60',
CORS_ALLOWED_ORIGINS: 'https://a.example.com, http://localhost:5173',
TRIVELA_API_KEYS: 'key-one,key-two',
LOCK_TTL_MS: '5000',
EXPORT_RETENTION_DAYS: '30',
}),
);
});

test('numeric settings must be positive integers', () => {
for (const bad of ['0', '-1', 'abc']) {
assert.throws(() => validateBackendEnv({ PORT: bad }), /PORT must be a positive integer/, bad);
}
assert.throws(
() => validateBackendEnv({ RATE_LIMIT_MAX_REQUESTS: '0' }),
/RATE_LIMIT_MAX_REQUESTS/,
);
assert.throws(
() => validateBackendEnv({ SOFT_DELETE_RETENTION_DAYS: 'x' }),
/SOFT_DELETE_RETENTION_DAYS/,
);
});

test('every problem is reported together in one error', () => {
try {
validateBackendEnv({ PORT: '0', LOCK_TTL_MS: 'x', EXPORT_RETENTION_DAYS: '-2' });
assert.fail('expected a validation error');
} catch (error) {
assert.match(error.message, /Invalid environment configuration:/);
assert.match(error.message, /- PORT must be a positive integer/);
assert.match(error.message, /- LOCK_TTL_MS must be a positive integer/);
assert.match(error.message, /- EXPORT_RETENTION_DAYS must be a positive integer/);
assert.match(error.message, /Fix the values and try again\./);
}
});

test('CORS origins must be valid http(s) URLs, with * allowed', () => {
assert.doesNotThrow(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: '*' }));
assert.doesNotThrow(() => validateBackendEnv({ CORS_ORIGIN: 'https://ok.example.com' }));
assert.throws(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: 'not a url' }), /invalid origin/);
assert.throws(
() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: 'ftp://files.example.com' }),
/must be http\(s\)/,
);
assert.throws(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: ' , ' }), /at least one origin/);
});

test('API key lists tolerate stray commas but must contain at least one key', () => {
assert.doesNotThrow(() => validateBackendEnv({ TRIVELA_API_KEY: 'solo' }));
assert.doesNotThrow(() => validateBackendEnv({ TRIVELA_API_KEYS: 'a,,b,' }));
assert.throws(() => validateBackendEnv({ TRIVELA_API_KEYS: ' , ' }), /at least one key/);
});

test('DB_PATH must be a string when set', () => {
assert.throws(() => validateBackendEnv({ DB_PATH: 123 }), /DB_PATH must be a string path/);
assert.doesNotThrow(() => validateBackendEnv({ DB_PATH: './trivela.db' }));
});
Loading