From fd882697f212760b708e488795d1c8ee2aca5c99 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:18:01 +0100 Subject: [PATCH 1/6] test(export): add unit tests for CSV, JSON, rate limit, date filter, audit log, and fallback --- backend/src/routes/campaignExport.test.js | 506 ++++++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 backend/src/routes/campaignExport.test.js diff --git a/backend/src/routes/campaignExport.test.js b/backend/src/routes/campaignExport.test.js new file mode 100644 index 00000000..c91e647b --- /dev/null +++ b/backend/src/routes/campaignExport.test.js @@ -0,0 +1,506 @@ +// @ts-check +/** + * Unit tests for the campaign export route. + * Run with: node --test src/routes/campaignExport.test.js + */ + +import test, { describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCampaignExportRoute } from './campaignExport.js'; + +// ── Fakes ───────────────────────────────────────────────────────────────────── + +function makeDb(rows = [], { hasCreditEvents = true, hasClaimEvents = true } = {}) { + return { + prepare(sql) { + if (sql.includes("sqlite_master") && sql.includes("credit_events")) { + return { get: () => hasCreditEvents ? { name: 'credit_events' } : undefined }; + } + if (sql.includes("sqlite_master") && sql.includes("claim_events")) { + return { get: () => hasClaimEvents ? { name: 'claim_events' } : undefined }; + } + return { all: (..._args) => rows, get: () => undefined }; + }, + }; +} + +function makeCampaignRepo(campaign = null) { + return { getById: (id) => campaign ?? (id === 'camp1' ? { id: 'camp1', name: 'Test Campaign' } : null) }; +} + +function makeAuditRepo() { + const calls = []; + return { + create: (entry) => calls.push(entry), + calls, + }; +} + +function makeRequireApiKey(req, res, next) { + next(); +} + +function makeReq({ params = {}, query = {}, headers = {}, ip = '1.2.3.4' } = {}) { + return { params, query, headers, ip, path: '/campaigns/' + (params.id ?? 'x') + '/export' }; +} + +function makeRes() { + const res = { + _status: 200, + _headers: {}, + _body: null, + status(code) { this._status = code; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(body) { this._body = body; return this; }, + }; + return res; +} + +// Wraps pipeline streaming into a buffer we can assert against +function makeStreamRes() { + const res = makeRes(); + const chunks = []; + res.write = (chunk) => chunks.push(chunk); + res.end = () => {}; + res.on = (event, cb) => { if (event === 'drain') {} }; + res.once = () => res; + res.emit = () => false; + res.writable = true; + res.writableEnded = false; + res.writableFinished = false; + res._getBody = () => chunks.join(''); + return res; +} + +async function callExport(handler, req, res) { + return new Promise((resolve) => { + handler(req, res, resolve); + }); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getRouter(opts = {}) { + const db = opts.db ?? makeDb(opts.rows ?? []); + const campaignRepository = opts.campaignRepo ?? makeCampaignRepo(); + const auditLogRepository = opts.auditRepo ?? makeAuditRepo(); + const requireApiKey = opts.requireApiKey ?? makeRequireApiKey; + return createCampaignExportRoute({ db, campaignRepository, auditLogRepository, requireApiKey }); +} + +function routeHandler(router) { + // Extract the GET handler from the Express router stack + const layer = router.stack.find((l) => l.route?.path === '/campaigns/:id/export'); + if (!layer) throw new Error('Route not found'); + const handlers = layer.route.stack.map((s) => s.handle); + return async (req, res) => { + let i = 0; + const next = () => { i++; if (i < handlers.length) return handlers[i](req, res, next); }; + return handlers[0](req, res, next); + }; +} + +// ── Tests: validation ──────────────────────────────────────────────────────── + +describe('campaignExport — validation', () => { + test('returns 400 for invalid format', async () => { + const router = getRouter(); + const handler = routeHandler(router); + const req = makeReq({ params: { id: 'camp1' }, query: { format: 'xml' } }); + const res = makeRes(); + + await handler(req, res); + + assert.equal(res._status, 400); + assert.equal(res._body?.code, 'INVALID_FORMAT'); + }); + + test('returns 404 when campaign does not exist', async () => { + const router = getRouter({ campaignRepo: { getById: () => null } }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: 'nonexistent' }, query: { format: 'csv' } }); + const res = makeRes(); + + await handler(req, res); + + assert.equal(res._status, 404); + assert.equal(res._body?.code, 'NOT_FOUND'); + }); +}); + +// ── Tests: rate limiting ───────────────────────────────────────────────────── + +describe('campaignExport — rate limiting', () => { + test('sets X-RateLimit-Limit and X-RateLimit-Remaining headers', async () => { + // Use a unique campaign ID to avoid state leakage from other tests + const uniqueId = `rl-headers-${Date.now()}`; + const router = getRouter({ + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'RL Test' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': 'key-rl-1' } }); + const res = makeRes(); + + await handler(req, res); + + assert.equal(res._headers['x-ratelimit-limit'], '5'); + assert.ok(res._headers['x-ratelimit-remaining'] !== undefined); + }); + + test('returns 429 after exceeding 5 exports per campaign per actor', async () => { + const uniqueId = `rl-throttle-${Date.now()}`; + const router = getRouter({ + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'RL Throttle' } : null }, + }); + const handler = routeHandler(router); + + // Exhaust 5 allowed exports + for (let i = 0; i < 5; i++) { + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': 'key-rl-throttle' } }); + const res = makeRes(); + await handler(req, res); + } + + // 6th should be rate-limited + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': 'key-rl-throttle' } }); + const res = makeRes(); + await handler(req, res); + + assert.equal(res._status, 429); + assert.equal(res._body?.code, 'EXPORT_RATE_LIMIT_EXCEEDED'); + assert.ok(res._headers['retry-after']); + }); + + test('different API keys have independent rate limit buckets', async () => { + const uniqueId = `rl-keys-${Date.now()}`; + const router = getRouter({ + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'RL Keys' } : null }, + }); + const handler = routeHandler(router); + + // Exhaust key-A + for (let i = 0; i < 5; i++) { + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': 'key-A-isolated' } }); + const res = makeRes(); + await handler(req, res); + } + + // key-B should still be allowed + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': 'key-B-isolated' } }); + const res = makeRes(); + await handler(req, res); + + assert.notEqual(res._status, 429); + }); +}); + +// ── Tests: CSV format ──────────────────────────────────────────────────────── + +describe('campaignExport — CSV format', () => { + const PARTICIPANT_ROWS = [ + { participantAddress: 'GABC', registeredAt: '2026-01-01', pointsCredited: 100, pointsClaimed: 50, netPoints: 50, referredBy: 'GXYZ' }, + { participantAddress: 'GDEF', registeredAt: '2026-01-02', pointsCredited: 200, pointsClaimed: 0, netPoints: 200, referredBy: null }, + ]; + + test('sets Content-Type text/csv and Content-Disposition attachment', async () => { + const uniqueId = `csv-headers-${Date.now()}`; + const router = getRouter({ + rows: PARTICIPANT_ROWS, + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'CSV' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': `k-${uniqueId}` } }); + const res = makeRes(); + + await handler(req, res); + + assert.ok(res._headers['content-type']?.includes('text/csv')); + assert.ok(res._headers['content-disposition']?.includes('attachment')); + assert.ok(res._headers['content-disposition']?.includes('.csv')); + }); + + test('CSV header row contains all required columns', async () => { + const uniqueId = `csv-cols-${Date.now()}`; + const auditRepo = makeAuditRepo(); + const router = getRouter({ + rows: PARTICIPANT_ROWS, + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'CSV Cols' } : null }, + auditRepo, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': `k-${uniqueId}` } }); + + // Capture streamed output by intercepting the underlying stream pipeline + let csvBody = ''; + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write(chunk) { csvBody += chunk; }, + end() {}, + on(e, cb) { return this; }, + once(e, cb) { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + const firstLine = csvBody.split('\n')[0] ?? ''; + assert.ok(firstLine.includes('participantAddress'), 'missing participantAddress'); + assert.ok(firstLine.includes('registeredAt'), 'missing registeredAt'); + assert.ok(firstLine.includes('pointsCredited'), 'missing pointsCredited'); + assert.ok(firstLine.includes('pointsClaimed'), 'missing pointsClaimed'); + assert.ok(firstLine.includes('netPoints'), 'missing netPoints'); + assert.ok(firstLine.includes('referredBy'), 'missing referredBy'); + }); + + test('CSV escapes values containing commas', async () => { + const { buildCsv } = await import('./campaignExport.js').catch(() => ({})); + if (!buildCsv) return; // not exported — skip + + const columns = ['a', 'b']; + const rows = [{ a: 'hello, world', b: 'normal' }]; + const csv = buildCsv(columns, rows); + assert.ok(csv.includes('"hello, world"')); + }); +}); + +// ── Tests: JSON format ─────────────────────────────────────────────────────── + +describe('campaignExport — JSON format', () => { + test('sets Content-Type application/json and Content-Disposition attachment', async () => { + const uniqueId = `json-headers-${Date.now()}`; + const router = getRouter({ + rows: [], + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'JSON Test' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'json' }, headers: { 'x-api-key': `k-${uniqueId}` } }); + + let body = ''; + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write(chunk) { body += chunk; }, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + assert.ok(res._headers['content-type']?.includes('application/json')); + assert.ok(res._headers['content-disposition']?.includes('attachment')); + assert.ok(res._headers['content-disposition']?.includes('.json')); + }); + + test('JSON export includes campaign metadata and participants array', async () => { + const uniqueId = `json-shape-${Date.now()}`; + const router = getRouter({ + rows: [{ participantAddress: 'GABC', registeredAt: '2026-01-01', pointsCredited: 10, pointsClaimed: 0, netPoints: 10, referredBy: null }], + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'Shape Test' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'json' }, headers: { 'x-api-key': `k-${uniqueId}` } }); + + let body = ''; + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write(chunk) { body += chunk; }, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + const parsed = JSON.parse(body); + assert.ok('campaign' in parsed, 'missing campaign key'); + assert.ok('participants' in parsed, 'missing participants key'); + assert.ok(Array.isArray(parsed.participants)); + assert.equal(parsed.campaign.id, uniqueId); + assert.equal(parsed.campaign.name, 'Shape Test'); + }); +}); + +// ── Tests: date range filter ───────────────────────────────────────────────── + +describe('campaignExport — date range filter', () => { + test('accepts ?from and ?to without error when credit_events table is absent', async () => { + const uniqueId = `date-filter-${Date.now()}`; + const router = getRouter({ + rows: [], + db: makeDb([], { hasCreditEvents: false }), + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'Date Test' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ + params: { id: uniqueId }, + query: { format: 'csv', from: '2026-01-01', to: '2026-06-01' }, + headers: { 'x-api-key': `k-${uniqueId}` }, + }); + + let body = ''; + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write(chunk) { body += chunk; }, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + assert.notEqual(res._status, 400); + assert.notEqual(res._status, 500); + }); +}); + +// ── Tests: audit log ───────────────────────────────────────────────────────── + +describe('campaignExport — audit log', () => { + test('creates an audit log entry on every successful export', async () => { + const uniqueId = `audit-${Date.now()}`; + const auditRepo = makeAuditRepo(); + const router = getRouter({ + rows: [], + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'Audit Test' } : null }, + auditRepo, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': `k-audit-${uniqueId}` } }); + + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write() {}, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + assert.equal(auditRepo.calls.length, 1); + const entry = auditRepo.calls[0]; + assert.equal(entry.action, 'campaign.export'); + assert.equal(entry.entity, 'campaign'); + assert.equal(entry.entityId, uniqueId); + assert.equal(entry.diff.format, 'csv'); + }); + + test('does not fail if audit log throws', async () => { + const uniqueId = `audit-fail-${Date.now()}`; + const failingAuditRepo = { create: () => { throw new Error('audit DB down'); } }; + const router = getRouter({ + rows: [], + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'Audit Fail' } : null }, + auditRepo: failingAuditRepo, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'csv' }, headers: { 'x-api-key': `k-af-${uniqueId}` } }); + + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write() {}, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await assert.doesNotReject(() => handler(req, res)); + }); +}); + +// ── Tests: referrals-only fallback ─────────────────────────────────────────── + +describe('campaignExport — referrals-only fallback', () => { + test('falls back to referrals when credit_events table does not exist', async () => { + const uniqueId = `fallback-${Date.now()}`; + const referralRows = [ + { referee_address: 'GABC', referrer_address: 'GXYZ', created_at: '2026-01-01' }, + ]; + const router = getRouter({ + db: makeDb(referralRows, { hasCreditEvents: false }), + campaignRepo: { getById: (id) => id === uniqueId ? { id: uniqueId, name: 'Fallback Test' } : null }, + }); + const handler = routeHandler(router); + const req = makeReq({ params: { id: uniqueId }, query: { format: 'json' }, headers: { 'x-api-key': `k-fb-${uniqueId}` } }); + + let body = ''; + const res = { + _status: 200, + _headers: {}, + status(c) { this._status = c; return this; }, + setHeader(k, v) { this._headers[k.toLowerCase()] = v; }, + json(b) { this._body = b; return this; }, + write(chunk) { body += chunk; }, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return false; }, + writable: true, + writableEnded: false, + writableFinished: false, + destroy() {}, + }; + + await handler(req, res); + + const parsed = JSON.parse(body); + assert.equal(parsed.participants.length, 1); + assert.equal(parsed.participants[0].participantAddress, 'GABC'); + assert.equal(parsed.participants[0].referredBy, 'GXYZ'); + assert.equal(parsed.participants[0].pointsCredited, 0); + assert.equal(parsed.participants[0].pointsClaimed, 0); + }); +}); From 9e95822cbdaf0fb883ef350071b84ff78dd06cf7 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:18:09 +0100 Subject: [PATCH 2/6] feat(deprecation): make registry injectable for testability --- backend/src/middleware/deprecationNotice.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/src/middleware/deprecationNotice.js b/backend/src/middleware/deprecationNotice.js index c933f9db..94c204d7 100644 --- a/backend/src/middleware/deprecationNotice.js +++ b/backend/src/middleware/deprecationNotice.js @@ -2,16 +2,21 @@ import { DEPRECATION_REGISTRY } from '../deprecations.js'; /** - * Match a request path+method against the deprecation registry. + * @typedef {{ deprecatedAt: string, removedAt: string, replacement: string, message: string }} DeprecationEntry + */ + +/** + * Match a request path+method against a deprecation registry. * Registry keys are like "GET /api/v1/campaigns/:id/stats"; path segments * starting with ":" are treated as wildcards. * * @param {string} method e.g. "GET" * @param {string} path e.g. "/api/v1/campaigns/42/stats" - * @returns {import('../deprecations.js').DeprecationEntry | null} + * @param {Record} registry + * @returns {DeprecationEntry | null} */ -function matchDeprecation(method, path) { - for (const [pattern, entry] of Object.entries(DEPRECATION_REGISTRY)) { +function matchDeprecation(method, path, registry) { + for (const [pattern, entry] of Object.entries(registry)) { const [patternMethod, ...rest] = pattern.split(' '); const patternPath = rest.join(' '); @@ -36,12 +41,12 @@ function matchDeprecation(method, path) { * any route registered in the deprecation registry, and WARN-logs usage * so operators know which deprecated endpoints are still being hit. * - * @param {{ log?: { warn?: Function } }} [options] + * @param {{ log?: { warn?: Function }, registry?: Record }} [options] * @returns {import('express').RequestHandler} */ -export function createDeprecationMiddleware({ log = console } = {}) { +export function createDeprecationMiddleware({ log = console, registry = DEPRECATION_REGISTRY } = {}) { return function deprecationNotice(req, res, next) { - const entry = matchDeprecation(req.method, req.path); + const entry = matchDeprecation(req.method, req.path, registry); if (entry) { const deprecationDate = new Date(entry.deprecatedAt).toUTCString(); From b33e1786e24d8787edc9277e8bd3ff753c735cae Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:18:21 +0100 Subject: [PATCH 3/6] test(deprecation): add unit tests for middleware header injection, matching, and logging --- .../src/middleware/deprecationNotice.test.js | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 backend/src/middleware/deprecationNotice.test.js diff --git a/backend/src/middleware/deprecationNotice.test.js b/backend/src/middleware/deprecationNotice.test.js new file mode 100644 index 00000000..2801cc3f --- /dev/null +++ b/backend/src/middleware/deprecationNotice.test.js @@ -0,0 +1,159 @@ +// @ts-check +/** + * Unit tests for the deprecation notice middleware. + * Run with: node --test src/middleware/deprecationNotice.test.js + */ + +import test, { describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDeprecationMiddleware } from './deprecationNotice.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeReqRes({ method = 'GET', path = '/api/v1/campaigns' } = {}) { + const req = { method, path }; + const headers = {}; + const res = { + setHeader(k, v) { headers[k] = v; }, + getHeaders: () => headers, + _headers: headers, + }; + return { req, res, headers }; +} + +function makeLogger() { + const warns = []; + return { + warn: (...args) => warns.push(args.join(' ')), + warns, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('createDeprecationMiddleware', () => { + test('calls next() even when no deprecation matches', (_, done) => { + const mw = createDeprecationMiddleware({}); + const { req, res } = makeReqRes({ path: '/api/v1/campaigns' }); + mw(req, res, () => done()); + }); + + test('does not set headers when route is not deprecated', () => { + const mw = createDeprecationMiddleware({}); + const { req, res, headers } = makeReqRes({ path: '/api/v1/campaigns' }); + mw(req, res, () => {}); + assert.ok(!headers['Deprecation'], 'should not set Deprecation header'); + assert.ok(!headers['Sunset'], 'should not set Sunset header'); + assert.ok(!headers['Link'], 'should not set Link header'); + }); + + test('sets Deprecation, Sunset, and Link headers for a registered route', () => { + const registry = { + 'GET /api/campaigns': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns', + message: 'Use /api/v1/campaigns instead.', + }, + }; + const mw = createDeprecationMiddleware({ registry }); + const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns' }); + mw(req, res, () => {}); + assert.ok(headers['Deprecation'], 'missing Deprecation header'); + assert.ok(headers['Sunset'], 'missing Sunset header'); + assert.ok(headers['Link'], 'missing Link header'); + assert.ok(headers['Link'].includes('/api/v1/campaigns'), 'Link should point to replacement'); + }); + + test('matches parameterized segments — /api/campaigns/:id', () => { + const registry = { + 'GET /api/campaigns/:id': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns/:id', + message: 'Use /api/v1/campaigns/:id instead.', + }, + }; + const mw = createDeprecationMiddleware({ registry }); + const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns/abc123' }); + mw(req, res, () => {}); + assert.ok(headers['Deprecation'], 'parameterized path should match'); + }); + + test('does not match wrong HTTP method', () => { + const registry = { + 'GET /api/campaigns': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns', + message: '', + }, + }; + const mw = createDeprecationMiddleware({ registry }); + const { req, res, headers } = makeReqRes({ method: 'POST', path: '/api/campaigns' }); + mw(req, res, () => {}); + assert.ok(!headers['Deprecation'], 'should not match a different HTTP method'); + }); + + test('does not match a path with a different segment count', () => { + const registry = { + 'GET /api/campaigns': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns', + message: '', + }, + }; + const mw = createDeprecationMiddleware({ registry }); + const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns/extra/segment' }); + mw(req, res, () => {}); + assert.ok(!headers['Deprecation'], 'should not match paths with extra segments'); + }); + + test('WARN-logs on deprecated endpoint hit', () => { + const registry = { + 'GET /api/campaigns': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns', + message: 'Legacy route.', + }, + }; + const log = makeLogger(); + const mw = createDeprecationMiddleware({ log, registry }); + const { req, res } = makeReqRes({ method: 'GET', path: '/api/campaigns' }); + mw(req, res, () => {}); + assert.equal(log.warns.length, 1); + assert.ok(log.warns[0].includes('deprecated_endpoint_hit')); + assert.ok(log.warns[0].includes('/api/campaigns')); + }); + + test('does not WARN-log for non-deprecated routes', () => { + const log = makeLogger(); + const mw = createDeprecationMiddleware({ log, registry: {} }); + const { req, res } = makeReqRes({ path: '/api/v1/campaigns' }); + mw(req, res, () => {}); + assert.equal(log.warns.length, 0); + }); + + test('Deprecation header value is a valid HTTP date string', () => { + const registry = { + 'GET /api/campaigns': { + deprecatedAt: '2026-01-01', + removedAt: '2026-12-31', + replacement: '/api/v1/campaigns', + message: '', + }, + }; + const mw = createDeprecationMiddleware({ registry }); + const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns' }); + mw(req, res, () => {}); + assert.ok(!isNaN(Date.parse(headers['Deprecation'])), 'Deprecation header should be a parseable date'); + }); + + test('handles an empty registry without errors', () => { + const mw = createDeprecationMiddleware({ registry: {} }); + const { req, res } = makeReqRes({ path: '/api/v1/anything' }); + assert.doesNotThrow(() => mw(req, res, () => {})); + }); +}); From 87ceba29ee6a52d3d7fccf90824f0ca4762c0369 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:18:37 +0100 Subject: [PATCH 4/6] feat(deprecation): register legacy /api/* routes as deprecated with 90-day sunset --- backend/src/deprecations.js | 61 +++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/backend/src/deprecations.js b/backend/src/deprecations.js index b89de675..b0373df2 100644 --- a/backend/src/deprecations.js +++ b/backend/src/deprecations.js @@ -1,17 +1,60 @@ // @ts-check +/** + * @typedef {{ deprecatedAt: string, removedAt: string, replacement: string, message: string }} DeprecationEntry + */ + /** * Deprecation registry — maps route patterns to lifecycle metadata. - * Add entries here before removing or replacing any endpoint. * - * @type {Record} + * All legacy /api/* routes are deprecated in favour of their /api/v1/* equivalents. + * A minimum 90-day notice period applies. Add entries here before removing or + * replacing any endpoint; the deprecationNotice middleware reads this map at + * runtime and injects RFC 8594 Deprecation / Sunset / Link headers automatically. + * + * @type {Record} */ export const DEPRECATION_REGISTRY = { - // Example — uncomment when real deprecations land: - // 'GET /api/v1/campaigns/:id/stats': { - // deprecatedAt: '2024-09-01', - // removedAt: '2024-12-01', - // replacement: '/api/v1/campaigns/:id/analytics', - // message: 'Use the /analytics endpoint for richer campaign stats.', - // }, + 'GET /api/campaigns': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns', + message: 'Use GET /api/v1/campaigns for the versioned campaign list.', + }, + 'GET /api/campaigns/:id': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns/:id', + message: 'Use GET /api/v1/campaigns/:id for the versioned campaign detail.', + }, + 'POST /api/campaigns': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns', + message: 'Use POST /api/v1/campaigns to create campaigns.', + }, + 'PUT /api/campaigns/:id': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns/:id', + message: 'Use PUT /api/v1/campaigns/:id to update campaigns.', + }, + 'DELETE /api/campaigns/:id': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns/:id', + message: 'Use DELETE /api/v1/campaigns/:id to delete campaigns.', + }, + 'GET /api/campaigns/:id/stats': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns/:id/stats', + message: 'Use GET /api/v1/campaigns/:id/stats for the versioned stats endpoint.', + }, + 'GET /api/campaigns/:id/export': { + deprecatedAt: '2026-06-01', + removedAt: '2026-09-01', + replacement: '/api/v1/campaigns/:id/export', + message: 'Use GET /api/v1/campaigns/:id/export for the versioned export endpoint.', + }, }; From 9768fd4be9178ff4601db2ced933ddcf77b14110 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:18:54 +0100 Subject: [PATCH 5/6] feat(profile): add retry button on error, Refresh button, and empty-state guidance --- frontend/src/pages/UserProfile.jsx | 205 +++++++++++++++++------------ 1 file changed, 122 insertions(+), 83 deletions(-) diff --git a/frontend/src/pages/UserProfile.jsx b/frontend/src/pages/UserProfile.jsx index 8dd1e4ba..1d7bc138 100644 --- a/frontend/src/pages/UserProfile.jsx +++ b/frontend/src/pages/UserProfile.jsx @@ -36,7 +36,7 @@ function Skeleton({ width = '100%', height = '1.2em' }) { /** * Private profile page for the connected wallet. - * Redirects to wallet connect if no wallet is connected. + * Redirects home if no wallet is connected. */ export default function UserProfile({ theme, @@ -129,93 +129,132 @@ export default function UserProfile({ - +
+ + +
{error && ( -

- {error} -

+
+ {error} + +
)} -
- {loading ? ( - Array.from({ length: 4 }).map((_, i) => ( -
- - -
- )) - ) : ( - <> - - - - - - )} -
- -
-

Recent Activity

- {loading ? ( - Array.from({ length: 3 }).map((_, i) => ( -
- -
- )) - ) : !profile?.recentActivity?.length ? ( -

No activity yet.

- ) : ( -
    - {profile.recentActivity.map((event, i) => ( -
  • - {event.description ?? event.action} - -
  • - ))} -
- )} -
- -
-

Campaigns

- {loading ? ( - - ) : !profile?.campaigns?.length ? ( -

No campaigns yet.

- ) : ( -
    - {profile.campaigns.map((c) => ( -
  • - - {c.name} - - - {c.pointsEarned} pts - -
  • - ))} -
- )} -
- - {profile?.joinedDate && ( -

- Member since {new Date(profile.joinedDate).toLocaleDateString()} -

+ {!loading && profile?.empty && ( +
+

No activity yet

+

+ Join a campaign to start earning rewards.{' '} + Browse campaigns → +

+
+ )} + + {(loading || !profile?.empty) && ( + <> +
+ {loading ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ )) + ) : ( + <> + + + + + + )} +
+ +
+

Recent Activity

+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ )) + ) : !profile?.recentActivity?.length ? ( +

No activity yet.

+ ) : ( +
    + {profile.recentActivity.map((event, i) => ( +
  • + {event.description ?? event.action} + +
  • + ))} +
+ )} +
+ +
+

Campaigns

+ {loading ? ( + + ) : !profile?.campaigns?.length ? ( +

No campaigns yet.

+ ) : ( +
    + {profile.campaigns.map((c) => ( +
  • + + {c.name} + + + {c.pointsEarned} pts + +
  • + ))} +
+ )} +
+ + {profile?.joinedDate && ( +

+ Member since {new Date(profile.joinedDate).toLocaleDateString()} +

+ )} + )} From fcf0bc5f466ed6bc30ce8b6a150aa80381b86b71 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 15:19:08 +0100 Subject: [PATCH 6/6] feat(secrets): add PEM, JWT_SECRET, high-entropy, and KEEPER_SECRET_KEY patterns to gitleaks --- .gitleaks.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 9cbac587..c7241def 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,10 +15,35 @@ description = "Trivela API key pattern" regex = '''tvl_[a-zA-Z0-9]{32,}''' tags = ["trivela", "api-key"] +[[rules]] +id = "pem-private-key" +description = "PEM-encoded private key block" +regex = '''-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----''' +tags = ["pem", "private-key"] + +[[rules]] +id = "jwt-secret-env" +description = "JWT_SECRET value assignment in env files or shell" +regex = '''JWT_SECRET\s*=\s*['"]?[A-Za-z0-9+/=\-_]{16,}['"]?''' +tags = ["jwt", "secret"] + +[[rules]] +id = "generic-high-entropy-secret" +description = "Assignment to variables named *_SECRET or *_PRIVATE_KEY with high-entropy values" +regex = '''(?i)(secret|private_key)\s*[:=]\s*['"]?[A-Za-z0-9+/]{32,}['"]?''' +tags = ["generic", "secret"] + +[[rules]] +id = "keeper-secret-key" +description = "KEEPER_SECRET_KEY Stellar value" +regex = '''KEEPER_SECRET_KEY\s*=\s*['"]?S[A-Z2-7]{55}['"]?''' +tags = ["stellar", "keeper"] + [allowlist] description = "Safe paths — example files, test fixtures, docs" paths = [ '''.env\.example''', '''test[s]?/fixtures/''', '''docs/''', + '''\.gitleaks\.toml''', ]