From f33c7eb6fe45c490f06f63c322a6d2c81ec70307 Mon Sep 17 00:00:00 2001 From: zerroai <169384124+zerroai@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:13:47 +0100 Subject: [PATCH] test: offline Soroban RPC fixture, referral tests, task verification integration tests, cross-browser matrix Closes #1325, #1328, #1329, #1330 --- .github/workflows/frontend-ci.yml | 23 +- .../src/dal/sqliteReferralRepository.test.js | 134 +++++ .../taskVerification.integration.test.js | 483 ++++++++++++++++++ backend/src/mocks/mockSorobanRpc.js | 271 ++++++++++ backend/src/mocks/mockSorobanRpc.test.js | 125 +++++ backend/src/services/referralTiers.test.js | 74 +++ frontend/playwright.config.js | 26 + frontend/tests/e2e/README.md | 4 +- 8 files changed, 1135 insertions(+), 5 deletions(-) create mode 100644 backend/src/dal/sqliteReferralRepository.test.js create mode 100644 backend/src/integration/taskVerification.integration.test.js create mode 100644 backend/src/mocks/mockSorobanRpc.js create mode 100644 backend/src/mocks/mockSorobanRpc.test.js create mode 100644 backend/src/services/referralTiers.test.js diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d2c53564..23e068da 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -156,14 +156,31 @@ jobs: echo "**Minor violations**: Reported ℹ️" >> $GITHUB_STEP_SUMMARY cross-browser-e2e: - name: Cross-browser E2E (Chromium/Firefox/WebKit) + name: Cross-browser E2E (${{ matrix.browser }}) runs-on: ubuntu-latest needs: frontend + # Desktop Chrome, Firefox, Safari (WebKit) and Edge plus mobile Chrome, + # Safari and Edge. `install` is the Playwright browser to download for the + # project (mobile Chrome/Safari reuse the Chromium/WebKit engines). strategy: fail-fast: false matrix: - browser: [chromium, firefox, webkit] + include: + - browser: chromium + install: chromium + - browser: firefox + install: firefox + - browser: webkit + install: webkit + - browser: edge + install: msedge + - browser: mobile-chrome + install: chromium + - browser: mobile-safari + install: webkit + - browser: mobile-edge + install: msedge steps: - name: Check out repository @@ -185,7 +202,7 @@ jobs: path: frontend/dist - name: Install Playwright browser - ${{ matrix.browser }} - run: npx playwright install --with-deps ${{ matrix.browser }} + run: npx playwright install --with-deps ${{ matrix.install }} - name: Run E2E tests - ${{ matrix.browser }} run: npx playwright test --project=${{ matrix.browser }} diff --git a/backend/src/dal/sqliteReferralRepository.test.js b/backend/src/dal/sqliteReferralRepository.test.js new file mode 100644 index 00000000..65c95b68 --- /dev/null +++ b/backend/src/dal/sqliteReferralRepository.test.js @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Database from 'better-sqlite3'; +import { up as createReferrals } from '../db/migrations/002_referrals.js'; +import { getReferralTierProgress } from '../services/referralTiers.js'; +import { createSqliteReferralRepository } from './sqliteReferralRepository.js'; + +function createRepo() { + const db = new Database(':memory:'); + // Migration 002 alters the campaigns table, so provide the minimal one it expects. + db.exec('CREATE TABLE campaigns (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + createReferrals(db); + return { db, repo: createSqliteReferralRepository({ db }) }; +} + +const addr = (n) => `G${String(n).padStart(55, 'A')}`; + +test('create attributes a referee to the first referrer only', () => { + const { repo } = createRepo(); + + const first = repo.create({ campaignId: 1, referrerAddress: addr(1), refereeAddress: addr(9) }); + assert.equal(first.referrerAddress, addr(1)); + assert.equal(first.campaignId, '1'); + + // A later attempt by a different referrer must not steal the attribution. + const stolen = repo.create({ campaignId: 1, referrerAddress: addr(2), refereeAddress: addr(9) }); + assert.equal(stolen, null); + assert.equal(repo.getByRefereeAndCampaign(1, addr(9)).referrerAddress, addr(1)); + assert.equal(repo.countByReferrer(1, addr(2)), 0); +}); + +test('re-submitting the same referral is idempotent', () => { + const { repo } = createRepo(); + repo.create({ campaignId: 1, referrerAddress: addr(1), refereeAddress: addr(9) }); + assert.equal( + repo.create({ campaignId: 1, referrerAddress: addr(1), refereeAddress: addr(9) }), + null, + ); + assert.equal(repo.countByReferrer(1, addr(1)), 1); +}); + +test('attribution is per campaign: one wallet can be referred in several campaigns', () => { + const { repo } = createRepo(); + assert.ok(repo.create({ campaignId: 1, referrerAddress: addr(1), refereeAddress: addr(9) })); + assert.ok(repo.create({ campaignId: 2, referrerAddress: addr(2), refereeAddress: addr(9) })); + + assert.equal(repo.getByRefereeAndCampaign(1, addr(9)).referrerAddress, addr(1)); + assert.equal(repo.getByRefereeAndCampaign(2, addr(9)).referrerAddress, addr(2)); + assert.equal(repo.getByRefereeAndCampaign(3, addr(9)), null); +}); + +test('multi-level tree: each referrer is credited only for direct referrals', () => { + const { repo } = createRepo(); + // A refers B and C; B refers D, E, F; C refers G. + const [A, B, C, D, E, F, G] = [1, 2, 3, 4, 5, 6, 7].map(addr); + for (const [referrer, referee] of [ + [A, B], + [A, C], + [B, D], + [B, E], + [B, F], + [C, G], + ]) { + assert.ok(repo.create({ campaignId: 1, referrerAddress: referrer, refereeAddress: referee })); + } + + assert.equal(repo.countByReferrer(1, A), 2); + assert.equal(repo.countByReferrer(1, B), 3); + assert.equal(repo.countByReferrer(1, C), 1); + assert.equal(repo.countByReferrer(1, D), 0, 'leaves have no referrals'); + assert.equal(repo.listByCampaign(1).length, 6); +}); + +test('leaderboard ranks by direct count with ties sharing a rank and ties broken by first referral', () => { + const { repo } = createRepo(); + const [A, B, C] = [1, 2, 3].map(addr); + // A: 2, B: 2 (tie), C: 1 + repo.create({ campaignId: 1, referrerAddress: B, refereeAddress: addr(20) }); + repo.create({ campaignId: 1, referrerAddress: A, refereeAddress: addr(21) }); + repo.create({ campaignId: 1, referrerAddress: A, refereeAddress: addr(22) }); + repo.create({ campaignId: 1, referrerAddress: B, refereeAddress: addr(23) }); + repo.create({ campaignId: 1, referrerAddress: C, refereeAddress: addr(24) }); + + const { rows, total } = repo.getLeaderboard(1); + assert.equal(total, 3); + assert.deepEqual( + rows.map((r) => [r.referralCount, r.rank]), + [ + [2, 1], + [2, 1], + [1, 3], + ], + 'standard competition ranking: 1, 1, 3', + ); + assert.deepEqual(repo.getReferrerRank(1, A)?.rank, 1); + assert.deepEqual(repo.getReferrerRank(1, C), { referralCount: 1, rank: 3 }); + assert.equal(repo.getReferrerRank(1, addr(99)), null, 'no referrals means unranked'); +}); + +test('leaderboard paginates and is scoped to the campaign', () => { + const { repo } = createRepo(); + for (let i = 1; i <= 5; i += 1) { + for (let j = 0; j < i; j += 1) { + repo.create({ + campaignId: 1, + referrerAddress: addr(i), + refereeAddress: addr(100 + i * 10 + j), + }); + } + } + repo.create({ campaignId: 2, referrerAddress: addr(50), refereeAddress: addr(500) }); + + const page = repo.getLeaderboard(1, { limit: 2, offset: 1 }); + assert.equal(page.total, 5); + assert.deepEqual( + page.rows.map((r) => r.referralCount), + [4, 3], + ); + assert.equal(repo.getLeaderboard(2).total, 1); +}); + +test('referral counts feed the tier split: crossing 10 and 25 moves a referrer up a tier', () => { + const { repo } = createRepo(); + const referrer = addr(1); + const tierAfter = (n) => getReferralTierProgress(repo.countByReferrer(1, referrer)).tier.id; + + for (let i = 1; i <= 25; i += 1) { + repo.create({ campaignId: 1, referrerAddress: referrer, refereeAddress: addr(1000 + i) }); + if (i === 9) assert.equal(tierAfter(i), 'bronze'); + if (i === 10) assert.equal(tierAfter(i), 'silver'); + if (i === 24) assert.equal(tierAfter(i), 'silver'); + if (i === 25) assert.equal(tierAfter(i), 'gold'); + } +}); diff --git a/backend/src/integration/taskVerification.integration.test.js b/backend/src/integration/taskVerification.integration.test.js new file mode 100644 index 00000000..b2f2bf39 --- /dev/null +++ b/backend/src/integration/taskVerification.integration.test.js @@ -0,0 +1,483 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { createServer } from 'node:http'; +import test, { after, before, describe } from 'node:test'; +import Database from 'better-sqlite3'; +import express from 'express'; +import request from 'supertest'; +import { up as createGithubTaskTable } from '../db/migrations/044_github_task_events.js'; +import { createGithubTaskRoutes, createGithubWebhookRoutes } from '../routes/githubWebhook.js'; +import { createSocialTaskRoutes } from '../routes/socialTasks.js'; +import { createGithubTaskStore } from '../services/githubWebhook.js'; +import { createTwitterVerificationService } from '../services/twitterVerificationService.js'; + +// ── A local stand-in for the Twitter API v2 ────────────────────────────────── +// The real service, route and HTTP client run unmodified; only the third-party +// network edge is replaced, so these tests exercise the whole verification path. + +const TWITTER_USERS = { alice: '100', bob: '200', carol: '300' }; + +function createFakeTwitter() { + /** @type {{ url: string, auth: string | undefined }[]} */ + const requests = []; + /** @type {Record { status?: number, headers?: Record, body?: unknown }>} */ + const handlers = {}; + let nextOverride = null; + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + requests.push({ url: url.pathname + url.search, auth: req.headers.authorization }); + + let reply; + if (nextOverride) { + reply = nextOverride; + nextOverride = null; + } else { + const byUser = url.pathname.match(/^\/2\/users\/by\/username\/(.+)$/); + if (byUser) { + const id = TWITTER_USERS[decodeURIComponent(byUser[1]).toLowerCase()]; + reply = id ? { body: { data: { id, username: byUser[1] } } } : { body: {} }; + } else { + const key = Object.keys(handlers).find((pattern) => new RegExp(pattern).test(url.pathname)); + reply = key ? handlers[key](url) : { status: 404, body: {} }; + } + } + res.writeHead(reply.status ?? 200, { 'content-type': 'application/json', ...reply.headers }); + res.end(JSON.stringify(reply.body ?? {})); + }); + + return { + requests, + handlers, + failNextWith(reply) { + nextOverride = reply; + }, + listen: () => + new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)), + ), + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + +describe('Twitter task verification (route + service + HTTP client)', () => { + const twitter = createFakeTwitter(); + let baseUrl; + + before(async () => { + baseUrl = await twitter.listen(); + }); + after(() => twitter.close()); + + const appFor = (serviceOptions = {}) => { + const app = express(); + app.use(express.json()); + app.use( + '/social-tasks', + createSocialTaskRoutes({ + twitterVerificationService: createTwitterVerificationService({ + bearerToken: 'test-bearer', + baseUrl, + ...serviceOptions, + }), + }), + ); + return app; + }; + const verify = (app, body) => request(app).post('/social-tasks/twitter/verify').send(body); + + test('a user who liked the tweet is verified, using the bearer token', async () => { + twitter.handlers['/liking_users$'] = () => ({ body: { data: [{ id: '999' }, { id: '100' }] } }); + const res = await verify(appFor(), { + username: 'Alice', + task: { type: 'like', tweetId: '555' }, + }); + + assert.equal(res.status, 200); + assert.deepEqual(res.body, { verified: true, type: 'like', username: 'Alice', userId: '100' }); + assert.ok(twitter.requests.every((r) => r.auth === 'Bearer test-bearer')); + }); + + test('a user who did not like the tweet is not verified', async () => { + twitter.handlers['/liking_users$'] = () => ({ body: { data: [{ id: '999' }] } }); + const res = await verify(appFor(), { username: 'bob', task: { type: 'like', tweetId: '555' } }); + + assert.equal(res.status, 200); + assert.equal(res.body.verified, false); + assert.equal(res.body.reason, 'LIKE_NOT_FOUND'); + }); + + test('retweet and follow tasks are checked against their own endpoints', async () => { + twitter.handlers['/retweeted_by$'] = () => ({ body: { data: [{ id: '200' }] } }); + twitter.handlers['/following$'] = () => ({ body: { data: [{ id: '300' }] } }); + const app = appFor(); + + const retweet = await verify(app, { username: 'bob', task: { type: 'retweet', tweetId: '1' } }); + assert.equal(retweet.body.verified, true); + + const follow = await verify(app, { + username: 'alice', + task: { type: 'follow', targetUsername: 'carol' }, + }); + assert.equal(follow.body.verified, true); + + const notFollowing = await verify(app, { + username: 'alice', + task: { type: 'follow', targetUsername: 'bob' }, + }); + assert.equal(notFollowing.body.verified, false); + assert.equal(notFollowing.body.reason, 'FOLLOW_NOT_FOUND'); + }); + + test('a tweet task matches recent tweet text case-insensitively', async () => { + twitter.handlers['/tweets$'] = () => ({ + body: { data: [{ id: '1', text: 'Just joined #Trivela rewards!' }] }, + }); + const app = appFor(); + + const hit = await verify(app, { username: 'alice', task: { type: 'tweet', text: '#trivela' } }); + assert.equal(hit.body.verified, true); + + const miss = await verify(app, { + username: 'alice', + task: { type: 'tweet', text: 'something else' }, + }); + assert.equal(miss.body.verified, false); + assert.equal(miss.body.reason, 'TWEET_NOT_FOUND'); + }); + + test('unknown users and unknown follow targets are unverified, not errors', async () => { + const app = appFor(); + const user = await verify(app, { username: 'nobody', task: { type: 'like', tweetId: '1' } }); + assert.equal(user.status, 200); + assert.equal(user.body.reason, 'USER_NOT_FOUND'); + + const target = await verify(app, { + username: 'alice', + task: { type: 'follow', targetUsername: 'ghost' }, + }); + assert.equal(target.body.reason, 'TARGET_NOT_FOUND'); + }); + + test('pagination is followed, but bounded by maxPages', async () => { + let pages = 0; + twitter.handlers['/liking_users$'] = () => { + pages += 1; + return { body: { data: [{ id: String(1000 + pages) }], meta: { next_token: `t${pages}` } } }; + }; + + pages = 0; + const bounded = await verify(appFor({ maxPages: 3 }), { + username: 'alice', + task: { type: 'like', tweetId: '9' }, + }); + assert.equal(bounded.body.verified, false); + assert.equal(pages, 3, 'stops after maxPages even though more pages exist'); + + // The match is on page 2. + pages = 0; + twitter.handlers['/liking_users$'] = () => { + pages += 1; + return pages === 2 + ? { body: { data: [{ id: '100' }] } } + : { body: { data: [{ id: 'x' }], meta: { next_token: 'next' } } }; + }; + const found = await verify(appFor(), { + username: 'alice', + task: { type: 'like', tweetId: '9' }, + }); + assert.equal(found.body.verified, true); + assert.equal(pages, 2); + }); + + test('Twitter rate limiting becomes a 429 with Retry-After', async () => { + twitter.failNextWith({ + status: 429, + headers: { 'x-rate-limit-reset': String(Math.floor(Date.now() / 1000) + 90) }, + }); + const res = await verify(appFor(), { username: 'alice', task: { type: 'like', tweetId: '1' } }); + + assert.equal(res.status, 429); + assert.equal(res.body.code, 'TWITTER_RATE_LIMITED'); + assert.ok(Number(res.headers['retry-after']) > 0 && Number(res.headers['retry-after']) <= 90); + }); + + test('rejected credentials and upstream outages are reported as 502', async () => { + const app = appFor(); + twitter.failNextWith({ status: 401 }); + const auth = await verify(app, { username: 'alice', task: { type: 'like', tweetId: '1' } }); + assert.equal(auth.status, 502); + assert.equal(auth.body.code, 'TWITTER_AUTH_FAILED'); + + twitter.failNextWith({ status: 500 }); + const outage = await verify(app, { username: 'alice', task: { type: 'like', tweetId: '1' } }); + assert.equal(outage.status, 502); + assert.equal(outage.body.code, 'TWITTER_UNAVAILABLE'); + }); + + test('an unreachable Twitter API is a 502, not a hang or a crash', async () => { + const res = await verify(appFor({ baseUrl: 'http://127.0.0.1:1', timeoutMs: 500 }), { + username: 'alice', + task: { type: 'like', tweetId: '1' }, + }); + assert.equal(res.status, 502); + assert.equal(res.body.code, 'TWITTER_UNAVAILABLE'); + }); + + test('a missing bearer token is a 503 and never calls Twitter', async () => { + const before = twitter.requests.length; + const app = express(); + app.use(express.json()); + app.use( + '/social-tasks', + createSocialTaskRoutes({ + twitterVerificationService: createTwitterVerificationService({ bearerToken: '', baseUrl }), + }), + ); + const res = await verify(app, { username: 'alice', task: { type: 'like', tweetId: '1' } }); + assert.equal(res.status, 503); + assert.equal(res.body.code, 'TWITTER_NOT_CONFIGURED'); + assert.equal(twitter.requests.length, before); + }); + + test('invalid input is rejected with 400 before any Twitter call', async () => { + const before = twitter.requests.length; + const app = appFor(); + + const badHandle = await verify(app, { + username: 'not a handle!', + task: { type: 'like', tweetId: '1' }, + }); + assert.equal(badHandle.status, 400); + assert.equal(badHandle.body.code, 'INVALID_USERNAME'); + + const badTask = await verify(app, { username: 'alice', task: { type: 'dance' } }); + assert.equal(badTask.status, 400); + assert.equal(badTask.body.code, 'INVALID_TASK'); + + const badTweetId = await verify(app, { + username: 'alice', + task: { type: 'like', tweetId: 'abc' }, + }); + assert.equal(badTweetId.status, 400); + + assert.equal(twitter.requests.length, before); + }); +}); + +// ── GitHub webhook → task store → admin verification lookup ────────────────── + +describe('GitHub task verification webhooks (route + store, signed payloads)', () => { + const SECRET = 'whsec_integration'; + const sign = (body, secret = SECRET) => + `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; + + const mergedPr = (over = {}) => ({ + action: 'closed', + repository: { full_name: 'acme/widgets' }, + pull_request: { + number: 12, + merged: true, + merged_at: '2026-10-01T00:00:00Z', + title: 'Fix bug', + html_url: 'https://github.com/acme/widgets/pull/12', + user: { login: 'Octo-Dev' }, + }, + ...over, + }); + + function setup({ secret = SECRET } = {}) { + const db = new Database(':memory:'); + createGithubTaskTable(db); + const store = createGithubTaskStore({ db, logger: {} }); + const app = express(); + app.use(createGithubWebhookRoutes({ store, getSecret: () => secret, logger: { warn() {} } })); + app.use('/admin/github-tasks', createGithubTaskRoutes({ store })); + return { app, store, db }; + } + + const deliver = ( + app, + payload, + { event = 'pull_request', id = 'd-1', signature, secret } = {}, + ) => { + const body = JSON.stringify(payload); + const req = request(app) + .post('/webhooks/github') + .set('content-type', 'application/json') + .set('x-github-event', event) + .set('x-hub-signature-256', signature ?? sign(body, secret)); + if (id) req.set('x-github-delivery', id); + return req.send(body); + }; + + test('a merged PR is recorded once and then visible to the verification lookup', async () => { + const { app } = setup(); + + const first = await deliver(app, mergedPr()); + assert.equal(first.status, 201); + assert.deepEqual(first.body, { + ok: true, + verified: true, + duplicate: false, + kind: 'pr_merged', + repo: 'acme/widgets', + number: 12, + }); + + // GitHub redelivering the same event (same delivery id) is harmless. + const redelivery = await deliver(app, mergedPr()); + assert.equal(redelivery.status, 200); + assert.equal(redelivery.body.duplicate, true); + + // The same task under a new delivery id is still a duplicate task. + const again = await deliver(app, mergedPr(), { id: 'd-2' }); + assert.equal(again.body.duplicate, true); + + const lookup = await request(app) + .get('/admin/github-tasks/verify') + .query({ githubLogin: 'octo-dev', repo: 'ACME/widgets', number: 12 }); + assert.equal(lookup.status, 200); + assert.equal(lookup.body.verified, true); + + const list = await request(app).get('/admin/github-tasks').query({ githubLogin: 'OCTO-DEV' }); + assert.equal(list.body.total, 1); + assert.equal(list.body.data[0].kind, 'pr_merged'); + }); + + test('a PR closed without merging does not verify anything', async () => { + const { app, store } = setup(); + const res = await deliver( + app, + mergedPr({ pull_request: { ...mergedPr().pull_request, merged: false } }), + ); + + assert.equal(res.status, 202); + assert.equal(res.body.ignored, true); + assert.equal(store.isVerified({ githubLogin: 'Octo-Dev' }), false); + }); + + test('closed issues verify the closer only when completed, not when "not planned"', async () => { + const { app, store } = setup(); + const issue = (reason) => ({ + action: 'closed', + repository: { full_name: 'acme/widgets' }, + issue: { + number: 7, + title: 'Bug', + state_reason: reason, + html_url: 'u', + closed_at: '2026-10-01T00:00:00Z', + }, + sender: { login: 'solver' }, + }); + + const notPlanned = await deliver(app, issue('not_planned'), { event: 'issues', id: 'i-1' }); + assert.equal(notPlanned.status, 202); + assert.equal(store.isVerified({ githubLogin: 'solver' }), false); + + const completed = await deliver(app, issue('completed'), { event: 'issues', id: 'i-2' }); + assert.equal(completed.status, 201); + assert.equal(completed.body.kind, 'issue_closed'); + assert.equal( + store.isVerified({ githubLogin: 'solver', kind: 'issue_closed', number: 7 }), + true, + ); + }); + + test('forged or missing signatures are rejected and nothing is recorded', async () => { + const { app, store } = setup(); + + const forged = await deliver(app, mergedPr(), { secret: 'attacker-secret' }); + assert.equal(forged.status, 401); + assert.equal(forged.body.code, 'INVALID_SIGNATURE'); + + const malformed = await deliver(app, mergedPr(), { signature: 'not-a-signature' }); + assert.equal(malformed.status, 401); + + const body = JSON.stringify(mergedPr()); + const missing = await request(app) + .post('/webhooks/github') + .set('content-type', 'application/json') + .set('x-github-event', 'pull_request') + .set('x-github-delivery', 'd-x') + .send(body); + assert.equal(missing.status, 401); + + assert.equal(store.list().length, 0); + }); + + test('a payload tampered after signing is rejected', async () => { + const { app } = setup(); + const signed = JSON.stringify(mergedPr()); + const tampered = signed.replace('Octo-Dev', 'Mallory'); + const res = await request(app) + .post('/webhooks/github') + .set('content-type', 'application/json') + .set('x-github-event', 'pull_request') + .set('x-github-delivery', 'd-t') + .set('x-hub-signature-256', sign(signed)) + .send(tampered); + assert.equal(res.status, 401); + }); + + test('an unconfigured secret is a 503', async () => { + const { app } = setup({ secret: '' }); + const res = await deliver(app, mergedPr(), { signature: 'sha256=00' }); + assert.equal(res.status, 503); + assert.equal(res.body.code, 'GITHUB_WEBHOOK_NOT_CONFIGURED'); + }); + + test('ping events, missing delivery ids and invalid JSON are handled', async () => { + const { app } = setup(); + + const ping = await deliver(app, { zen: 'hi' }, { event: 'ping' }); + assert.equal(ping.status, 200); + assert.equal(ping.body.event, 'ping'); + + const noId = await deliver(app, mergedPr(), { id: '' }); + assert.equal(noId.status, 400); + + const raw = '{not json'; + const invalid = await request(app) + .post('/webhooks/github') + .set('content-type', 'application/json') + .set('x-github-event', 'pull_request') + .set('x-github-delivery', 'd-bad') + .set('x-hub-signature-256', sign(raw)) + .send(raw); + assert.equal(invalid.status, 400); + }); + + test('unrelated events are acknowledged and ignored', async () => { + const { app } = setup(); + const res = await deliver( + app, + { repository: { full_name: 'acme/widgets' } }, + { event: 'push', id: 'p-1' }, + ); + assert.equal(res.status, 202); + assert.equal(res.body.ignored, true); + }); + + test('the verification lookup validates its inputs', async () => { + const { app } = setup(); + assert.equal((await request(app).get('/admin/github-tasks/verify')).status, 400); + assert.equal( + ( + await request(app) + .get('/admin/github-tasks/verify') + .query({ githubLogin: 'x', number: 'abc' }) + ).status, + 400, + ); + const unknown = await request(app) + .get('/admin/github-tasks/verify') + .query({ githubLogin: 'nobody' }); + assert.equal(unknown.body.verified, false); + }); +}); diff --git a/backend/src/mocks/mockSorobanRpc.js b/backend/src/mocks/mockSorobanRpc.js new file mode 100644 index 00000000..809738b1 --- /dev/null +++ b/backend/src/mocks/mockSorobanRpc.js @@ -0,0 +1,271 @@ +// @ts-check +import { createServer } from 'node:http'; + +/** + * Offline Soroban RPC fixture (#1325). + * + * A deterministic, in-memory stand-in for a Soroban JSON-RPC endpoint so unit + * tests never touch the network. Use it three ways: + * + * - `rpc.handle(body)` call the JSON-RPC handler directly. + * - `rpc.fetch` drop-in `fetch` for code that takes `fetchImpl` + * (e.g. `checkSorobanRpcHealth`). + * - `startMockSorobanRpcServer()` a real local HTTP server for clients that + * need a URL (e.g. `@stellar/stellar-sdk`'s `rpc.Server`). + * + * Defaults are valid-shaped responses for the common methods; override any of + * them per test with `setResponse`, inject failures with `failNext`, and + * inspect traffic through `calls`. + */ + +export const MOCK_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; + +/** + * @typedef {{ jsonrpc: '2.0', id: unknown, method: string, params?: any }} JsonRpcRequest + * @typedef {{ method: string, params: any }} RecordedCall + * @typedef {any | ((params: any, rpc: ReturnType) => any)} ResponseSpec + * @typedef {{ code?: number, message?: string, httpStatus?: number }} FailureSpec + */ + +/** + * @param {{ latestLedger?: number, networkPassphrase?: string, pendingPolls?: number }} [options] + * `pendingPolls` is how many `getTransaction` calls report NOT_FOUND before a + * submitted transaction resolves (default 0 = resolves immediately). + */ +export function createMockSorobanRpc({ + latestLedger = 1_000, + networkPassphrase = MOCK_NETWORK_PASSPHRASE, + pendingPolls = 0, +} = {}) { + let ledger = latestLedger; + let submitted = 0; + /** @type {RecordedCall[]} */ + const calls = []; + /** @type {Map} */ + const overrides = new Map(); + /** @type {Map} */ + const failures = new Map(); + /** @type {Map} */ + const transactions = new Map(); + + /** @type {Record any>} */ + const defaults = { + getHealth: () => ({ + status: 'healthy', + latestLedger: ledger, + oldestLedger: Math.max(1, ledger - 17_280), + ledgerRetentionWindow: 17_280, + }), + getNetwork: () => ({ + friendbotUrl: 'https://friendbot.stellar.org', + passphrase: networkPassphrase, + protocolVersion: 22, + }), + getLatestLedger: () => ({ + id: `mock-ledger-${ledger}`, + protocolVersion: 22, + sequence: ledger, + }), + getLedgerEntries: () => ({ entries: [], latestLedger: ledger }), + getEvents: () => ({ events: [], latestLedger: ledger, cursor: String(ledger) }), + simulateTransaction: () => ({ + latestLedger: ledger, + minResourceFee: '100', + cost: { cpuInsns: '0', memBytes: '0' }, + results: [], + }), + sendTransaction: () => { + submitted += 1; + const hash = submitted.toString(16).padStart(64, '0'); + transactions.set(hash, { + polls: 0, + notFoundPolls: pendingPolls, + result: { status: 'SUCCESS', latestLedger: ledger, ledger, applicationOrder: 1 }, + }); + return { status: 'PENDING', hash, latestLedger: ledger, latestLedgerCloseTime: '0' }; + }, + getTransaction: (params) => { + const hash = params?.hash; + const tx = hash ? transactions.get(hash) : undefined; + if (!tx) return { status: 'NOT_FOUND', latestLedger: ledger }; + tx.polls += 1; + if (tx.polls <= tx.notFoundPolls) return { status: 'NOT_FOUND', latestLedger: ledger }; + return { ...tx.result, latestLedger: ledger }; + }, + }; + + /** + * @param {JsonRpcRequest} request + * @returns {{ httpStatus: number, body: any }} + */ + function dispatch(request) { + const { id = null, method, params } = request ?? {}; + calls.push({ method, params }); + + const queued = failures.get(method); + if (queued?.length) { + const failure = /** @type {FailureSpec} */ (queued.shift()); + if (failure.httpStatus && failure.httpStatus >= 400) { + return { + httpStatus: failure.httpStatus, + body: { + jsonrpc: '2.0', + id, + error: { code: failure.code ?? -32603, message: failure.message ?? 'Internal error' }, + }, + }; + } + return { + httpStatus: 200, + body: { + jsonrpc: '2.0', + id, + error: { code: failure.code ?? -32603, message: failure.message ?? 'Internal error' }, + }, + }; + } + + const override = overrides.get(method); + const produce = override !== undefined ? override : defaults[method]; + if (produce === undefined) { + return { + httpStatus: 200, + body: { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }, + }; + } + const result = typeof produce === 'function' ? produce(params, api) : produce; + return { httpStatus: 200, body: { jsonrpc: '2.0', id, result } }; + } + + const api = { + /** Every request received, in order. */ + calls, + + /** Number of calls received for `method`. */ + callCount: (/** @type {string} */ method) => calls.filter((c) => c.method === method).length, + + /** Replace the response for `method` with a value or `(params, rpc) => value`. */ + setResponse(/** @type {string} */ method, /** @type {ResponseSpec} */ spec) { + overrides.set(method, spec); + return api; + }, + + /** + * Make the next `times` calls to `method` fail. By default this is a + * JSON-RPC error (HTTP 200); pass `httpStatus` (e.g. 503) for a transport failure. + */ + failNext(/** @type {string} */ method, /** @type {FailureSpec} */ failure = {}, times = 1) { + const queue = failures.get(method) ?? []; + for (let i = 0; i < times; i += 1) queue.push(failure); + failures.set(method, queue); + return api; + }, + + /** Pre-register the outcome of a transaction hash for `getTransaction`. */ + registerTransaction(/** @type {string} */ hash, /** @type {any} */ result, notFoundPolls = 0) { + transactions.set(hash, { polls: 0, notFoundPolls, result }); + return api; + }, + + /** Move the chain forward. */ + advanceLedger(n = 1) { + ledger += n; + return ledger; + }, + + get latestLedger() { + return ledger; + }, + + /** Forget overrides, queued failures, recorded calls and submitted transactions. */ + reset() { + calls.length = 0; + overrides.clear(); + failures.clear(); + transactions.clear(); + submitted = 0; + ledger = latestLedger; + }, + + /** Handle one JSON-RPC request object and return the JSON-RPC response envelope. */ + handle(/** @type {JsonRpcRequest} */ request) { + return dispatch(request).body; + }, + + /** `fetch`-compatible adapter: POST a JSON-RPC body, get a real `Response`. */ + fetch: async (/** @type {any} */ _url, /** @type {any} */ init = {}) => { + /** @type {JsonRpcRequest} */ + let request; + try { + request = JSON.parse(String(init.body ?? '{}')); + } catch { + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ); + } + const { httpStatus, body } = dispatch(request); + return new Response(JSON.stringify(body), { + status: httpStatus, + headers: { 'content-type': 'application/json' }, + }); + }, + }; + + return api; +} + +/** + * Serve a mock RPC over a local HTTP port (ephemeral by default). + * @param {ReturnType} [rpc] + * @returns {Promise<{ url: string, rpc: ReturnType, close: () => Promise }>} + */ +export async function startMockSorobanRpcServer(rpc = createMockSorobanRpc()) { + const server = createServer((req, res) => { + /** @type {Buffer[]} */ + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + let request; + try { + request = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + } catch { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }), + ); + return; + } + const response = rpc.fetch('', { body: JSON.stringify(request) }); + response.then(async (r) => { + res.writeHead(r.status, { 'content-type': 'application/json' }); + res.end(await r.text()); + }); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))); + const { port } = /** @type {import('node:net').AddressInfo} */ (server.address()); + + return { + url: `http://127.0.0.1:${port}`, + rpc, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections?.(); + server.close((err) => (err ? reject(err) : resolve(undefined))); + }), + }; +} diff --git a/backend/src/mocks/mockSorobanRpc.test.js b/backend/src/mocks/mockSorobanRpc.test.js new file mode 100644 index 00000000..4ec1494a --- /dev/null +++ b/backend/src/mocks/mockSorobanRpc.test.js @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { rpc as sdkRpc } from '@stellar/stellar-sdk'; +import { checkSorobanRpcHealth } from '../sorobanRpc.js'; +import { + createMockSorobanRpc, + MOCK_NETWORK_PASSPHRASE, + startMockSorobanRpcServer, +} from './mockSorobanRpc.js'; + +const call = (rpc, method, params) => rpc.handle({ jsonrpc: '2.0', id: 1, method, params }); + +test('default responses are valid JSON-RPC envelopes for the common methods', () => { + const rpc = createMockSorobanRpc({ latestLedger: 4242 }); + + assert.deepEqual(call(rpc, 'getLatestLedger').result.sequence, 4242); + assert.equal(call(rpc, 'getNetwork').result.passphrase, MOCK_NETWORK_PASSPHRASE); + assert.equal(call(rpc, 'getHealth').result.status, 'healthy'); + assert.deepEqual(call(rpc, 'getEvents').result.events, []); + assert.equal(call(rpc, 'getHealth').jsonrpc, '2.0'); + assert.equal(call(rpc, 'getHealth').id, 1); +}); + +test('unknown methods return a JSON-RPC method-not-found error', () => { + const res = call(createMockSorobanRpc(), 'noSuchMethod'); + assert.equal(res.error.code, -32601); + assert.match(res.error.message, /noSuchMethod/); +}); + +test('setResponse overrides a method with a value or a function of the params', () => { + const rpc = createMockSorobanRpc(); + rpc.setResponse('getLedgerEntries', { entries: [{ key: 'k' }], latestLedger: 1 }); + assert.equal(call(rpc, 'getLedgerEntries').result.entries[0].key, 'k'); + + rpc.setResponse('getEvents', (params) => ({ + events: [], + latestLedger: 9, + echoed: params.startLedger, + })); + assert.equal(call(rpc, 'getEvents', { startLedger: 77 }).result.echoed, 77); +}); + +test('failNext injects a bounded number of failures, then recovers', () => { + const rpc = createMockSorobanRpc(); + rpc.failNext('getHealth', { message: 'boom' }, 2); + + assert.equal(call(rpc, 'getHealth').error.message, 'boom'); + assert.equal(call(rpc, 'getHealth').error.message, 'boom'); + assert.equal(call(rpc, 'getHealth').result.status, 'healthy'); +}); + +test('calls are recorded in order and reset() clears state', () => { + const rpc = createMockSorobanRpc({ latestLedger: 10 }); + call(rpc, 'getHealth'); + call(rpc, 'getLatestLedger', { a: 1 }); + rpc.advanceLedger(5); + + assert.deepEqual( + rpc.calls.map((c) => c.method), + ['getHealth', 'getLatestLedger'], + ); + assert.equal(rpc.callCount('getHealth'), 1); + assert.equal(rpc.latestLedger, 15); + + rpc.reset(); + assert.equal(rpc.calls.length, 0); + assert.equal(rpc.latestLedger, 10); +}); + +test('sendTransaction yields a unique hash that getTransaction resolves, after pending polls', () => { + const rpc = createMockSorobanRpc({ pendingPolls: 2 }); + const a = call(rpc, 'sendTransaction', { transaction: 'x' }).result; + const b = call(rpc, 'sendTransaction', { transaction: 'y' }).result; + assert.equal(a.status, 'PENDING'); + assert.notEqual(a.hash, b.hash); + + assert.equal(call(rpc, 'getTransaction', { hash: a.hash }).result.status, 'NOT_FOUND'); + assert.equal(call(rpc, 'getTransaction', { hash: a.hash }).result.status, 'NOT_FOUND'); + assert.equal(call(rpc, 'getTransaction', { hash: a.hash }).result.status, 'SUCCESS'); + assert.equal(call(rpc, 'getTransaction', { hash: 'unknown' }).result.status, 'NOT_FOUND'); +}); + +test('registerTransaction can pin a failed outcome', () => { + const rpc = createMockSorobanRpc(); + rpc.registerTransaction('abc', { status: 'FAILED' }); + assert.equal(call(rpc, 'getTransaction', { hash: 'abc' }).result.status, 'FAILED'); +}); + +test('the fetch adapter works with code that accepts a fetchImpl', async () => { + const rpc = createMockSorobanRpc(); + const ok = await checkSorobanRpcHealth({ rpcUrl: 'https://rpc.invalid', fetchImpl: rpc.fetch }); + assert.equal(ok.status, 'ok'); + assert.equal(ok.result.passphrase, MOCK_NETWORK_PASSPHRASE); + assert.equal(rpc.callCount('getNetwork'), 1); + + rpc.failNext('getNetwork', { message: 'node down' }); + const rpcError = await checkSorobanRpcHealth({ + rpcUrl: 'https://rpc.invalid', + fetchImpl: rpc.fetch, + }); + assert.equal(rpcError.status, 'error'); + assert.equal(rpcError.error, 'node down'); + + rpc.failNext('getNetwork', { httpStatus: 503, message: 'unavailable' }); + const http = await checkSorobanRpcHealth({ rpcUrl: 'https://rpc.invalid', fetchImpl: rpc.fetch }); + assert.equal(http.status, 'error'); + assert.equal(http.httpStatus, 503); +}); + +test('the HTTP server works with the Stellar SDK RPC client, fully offline', async () => { + const server = await startMockSorobanRpcServer(createMockSorobanRpc({ latestLedger: 555 })); + try { + const client = new sdkRpc.Server(server.url, { allowHttp: true }); + const ledger = await client.getLatestLedger(); + assert.equal(ledger.sequence, 555); + const health = await client.getHealth(); + assert.equal(health.status, 'healthy'); + assert.deepEqual( + server.rpc.calls.map((c) => c.method), + ['getLatestLedger', 'getHealth'], + ); + } finally { + await server.close(); + } +}); diff --git a/backend/src/services/referralTiers.test.js b/backend/src/services/referralTiers.test.js new file mode 100644 index 00000000..08387552 --- /dev/null +++ b/backend/src/services/referralTiers.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getReferralTierProgress, REFERRAL_TIERS } from './referralTiers.js'; + +test('the tier ladder is ordered, starts at zero and has unique ids', () => { + assert.equal(REFERRAL_TIERS[0].minReferrals, 0); + for (let i = 1; i < REFERRAL_TIERS.length; i += 1) { + assert.ok(REFERRAL_TIERS[i].minReferrals > REFERRAL_TIERS[i - 1].minReferrals); + } + assert.equal(new Set(REFERRAL_TIERS.map((t) => t.id)).size, REFERRAL_TIERS.length); +}); + +test('every tier boundary lands in the right tier (inclusive lower bound)', () => { + const cases = [ + [0, 'bronze'], + [9, 'bronze'], + [10, 'silver'], + [24, 'silver'], + [25, 'gold'], + [49, 'gold'], + [50, 'platinum'], + [10_000, 'platinum'], + ]; + for (const [count, expected] of cases) { + assert.equal(getReferralTierProgress(count).tier.id, expected, `count ${count}`); + } +}); + +test('progress toward the next tier is computed from the current tier floor', () => { + // silver is 10..25 (span 15): 10 referrals = 0%, 17 = 47%, 24 = 93% + assert.deepEqual( + (({ progressPercent, referralsToNextTier, nextTier }) => ({ + progressPercent, + referralsToNextTier, + next: nextTier.id, + }))(getReferralTierProgress(10)), + { progressPercent: 0, referralsToNextTier: 15, next: 'gold' }, + ); + assert.equal(getReferralTierProgress(17).progressPercent, 47); + assert.equal(getReferralTierProgress(24).progressPercent, 93); + assert.equal(getReferralTierProgress(24).referralsToNextTier, 1); + + // first tier: 0..10 + assert.equal(getReferralTierProgress(0).progressPercent, 0); + assert.equal(getReferralTierProgress(5).progressPercent, 50); + assert.equal(getReferralTierProgress(5).nextTier.id, 'silver'); +}); + +test('the top tier has no next tier and reports full progress', () => { + const result = getReferralTierProgress(75); + assert.equal(result.tier.id, 'platinum'); + assert.equal(result.nextTier, null); + assert.equal(result.referralsToNextTier, 0); + assert.equal(result.progressPercent, 100); +}); + +test('invalid or negative counts are treated as zero referrals', () => { + for (const bad of [-5, NaN, Infinity * -1, undefined, null, 'x']) { + const result = getReferralTierProgress(/** @type {any} */ (bad)); + assert.equal(result.tier.id, 'bronze', `input ${String(bad)}`); + assert.equal(result.progressPercent, 0); + } +}); + +test('tier never decreases and progress stays within 0..100 as referrals grow', () => { + let previousIndex = 0; + for (let count = 0; count <= 80; count += 1) { + const { tier, progressPercent } = getReferralTierProgress(count); + const index = REFERRAL_TIERS.findIndex((t) => t.id === tier.id); + assert.ok(index >= previousIndex, `tier regressed at ${count}`); + assert.ok(progressPercent >= 0 && progressPercent <= 100); + previousIndex = index; + } +}); diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index a1bb49ad..73d7cdb9 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -1,5 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; +const mobileSpecs = ['**/cross-browser.spec.ts', '**/basic.spec.js']; + export default defineConfig({ testDir: './tests/e2e', timeout: 30_000, @@ -24,6 +26,30 @@ export default defineConfig({ name: 'webkit', use: { ...devices['Desktop Safari'] }, }, + // Desktop Edge (Chromium build with Microsoft's branding/channel). + { + name: 'edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + // Mobile engines (touch + small viewport): Chrome on Android, Safari on iOS, + // and Edge on Android. CI installs these browsers per matrix entry. They run + // the cross-browser smoke suite only: the wallet-extension specs assume a + // desktop browser. + { + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + testMatch: mobileSpecs, + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 14'] }, + testMatch: mobileSpecs, + }, + { + name: 'mobile-edge', + use: { ...devices['Pixel 7'], channel: 'msedge' }, + testMatch: mobileSpecs, + }, ], webServer: { command: 'npm run preview', diff --git a/frontend/tests/e2e/README.md b/frontend/tests/e2e/README.md index 6e3c4423..e07753d6 100644 --- a/frontend/tests/e2e/README.md +++ b/frontend/tests/e2e/README.md @@ -6,7 +6,7 @@ End-to-end tests for the Trivela frontend using Playwright. ### `cross-browser.spec.ts` -**Purpose**: Verify cross-browser compatibility across Chromium, Firefox, and WebKit. +**Purpose**: Verify cross-browser compatibility across desktop Chromium, Firefox, WebKit (Safari) and Edge, and mobile Chrome, Safari and Edge. **Coverage**: @@ -21,7 +21,7 @@ End-to-end tests for the Trivela frontend using Playwright. - ✅ Console error detection - ✅ Browser-specific features (smooth scroll, form rendering, DevTools) -**CI Integration**: Runs as a matrix job in `.github/workflows/frontend-ci.yml`, executing once per browser engine. +**CI Integration**: Runs as a matrix job in `.github/workflows/frontend-ci.yml`, executing once per Playwright project: `chromium`, `firefox`, `webkit`, `edge`, `mobile-chrome`, `mobile-safari`, `mobile-edge`. The Edge projects use the `msedge` channel (`npx playwright install msedge`). The mobile projects run only `cross-browser.spec.ts` and `basic.spec.js`; run one locally with `npx playwright test --project=mobile-safari`. ### `campaign-lifecycle.test.ts`