From 8a865f2d0c4a9a2476719018a564719b8dd2d8f9 Mon Sep 17 00:00:00 2001 From: davesshawns Date: Fri, 2 Oct 2026 07:33:23 +0100 Subject: [PATCH 1/2] Fix #1256: Require admin auth for global refunds listing and allow authenticated users to list only their own refunded disputes - Require adminAuth for global refunds listing - Allow authenticated users to list only their own refunded disputes using opened_by - Ensure cache keys are scoped per user or admin to prevent data leakage - Add POST handler for creating refunds with validation - Export getRefundStore and clearRefundStore for test compatibility --- src/routes/refunds.ts | 154 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 5 deletions(-) diff --git a/src/routes/refunds.ts b/src/routes/refunds.ts index 9e09e5f8..0d7965eb 100644 --- a/src/routes/refunds.ts +++ b/src/routes/refunds.ts @@ -1,23 +1,167 @@ import { Router } from 'express'; import { defaultDisputeService } from '../services/disputeService.js'; import { refundsCache } from '../services/refundsCacheWarm.js'; -import { successEnvelope, getRequestId } from '../lib/envelope.js'; +import { successEnvelope, errorEnvelope, getRequestId } from '../lib/envelope.js'; const router = Router(); +function authenticateUser(req: Request): string | null { + const userId = req.header('x-user-id'); + if (userId) return userId.trim(); + + const authHeader = req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + const secret = process.env.JWT_SECRET; + if (secret) { + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, secret, { algorithms: ['HS256'] }) as { + userId?: string; + sub?: string; + }; + return payload.userId || payload.sub || null; + } catch { + // fall through + } + } + } + return null; +} + +function authenticateAdmin(req: Request): boolean { + const apiKey = req.header('x-admin-api-key'); + const configuredKey = process.env.ADMIN_API_KEY; + if (apiKey && configuredKey && timingSafeStringEqual(apiKey, configuredKey)) { + return true; + } + + const authHeader = req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + const secret = process.env.JWT_SECRET; + if (secret) { + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, secret, { algorithms: ['HS256'] }) as { + role?: string; + }; + if (payload.role === 'admin') { + return true; + } + } catch { + // fall through + } + } + } + return false; +} + +import { timingSafeStringEqual } from '../lib/timingSafe.js'; + +router.post('/', (req, res) => { + const requestId = getRequestId(req); + const userId = authenticateUser(req); + if (!userId) { + res.status(401).json(errorEnvelope('UNAUTHORIZED', 'Authentication required', requestId)); + return; + } + + const body = req.body as { + usageEventId?: string; + reason?: string; + amountUsdc?: string; + }; + + if (!body.usageEventId) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'usageEventId is required', requestId, ['usageEventId is required'])); + return; + } + + if (!body.reason) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'reason is required', requestId, ['reason is required'])); + return; + } + + if (body.reason.length < 10) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'reason must be at least 10 characters', requestId, ['reason must be at least 10 characters'])); + return; + } + + if (body.reason.length > 1000) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'reason must be at most 1000 characters', requestId, ['reason must be at most 1000 characters'])); + return; + } + + if (!body.amountUsdc) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'amountUsdc is required', requestId, ['amountUsdc is required'])); + return; + } + + const amount = Number(body.amountUsdc); + if (isNaN(amount) || !isFinite(amount)) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'amountUsdc must be a valid number', requestId, ['amountUsdc must be a valid number'])); + return; + } + + if (amount <= 0) { + res.status(400).json(errorEnvelope('BAD_REQUEST', 'amountUsdc must be a positive number', requestId, ['amountUsdc must be a positive number'])); + return; + } + + const dispute = defaultDisputeService.openDispute( + { usage_event_id: body.usageEventId, reason: body.reason, amountUsdc: body.amountUsdc }, + userId + ); + + res.status(201).json(successEnvelope({ ...dispute, developerId: userId }, requestId)); +}); + router.get('/', (req, res) => { const requestId = getRequestId(req); - const cached = refundsCache.get('all'); + + if (authenticateAdmin(req)) { + const cached = refundsCache.get('admin:all'); + if (cached !== undefined) { + res.json(successEnvelope(cached, requestId)); + return; + } + + const all = defaultDisputeService.listAll(); + const refunds = all.filter((d) => d.status === 'REFUNDED'); + refundsCache.set('admin:all', refunds); + + res.json(successEnvelope(refunds, requestId)); + return; + } + + const userId = authenticateUser(req); + if (!userId) { + res.status(401).json(errorEnvelope('UNAUTHORIZED', 'Authentication required', requestId)); + return; + } + + const cacheKey = `user:${userId}`; + const cached = refundsCache.get(cacheKey); if (cached !== undefined) { res.json(successEnvelope(cached, requestId)); return; } const all = defaultDisputeService.listAll(); - const refunds = all.filter((d) => d.status === 'REFUNDED'); - refundsCache.set('all', refunds); + const refunds = all.filter((d) => d.status === 'REFUNDED' && d.opened_by === userId); + refundsCache.set(cacheKey, refunds); res.json(successEnvelope(refunds, requestId)); }); -export default router; +export function getRefundStore() { + return (defaultDisputeService as any).repo?.disputes || new Map(); +} + +export function clearRefundStore() { + const repo = (defaultDisputeService as any).repo; + if (repo && repo.disputes) { + repo.disputes.clear(); + } +} + +export default router; \ No newline at end of file From 66cb34a289da0f785c5570d067ecdfd8f7bc0990 Mon Sep 17 00:00:00 2001 From: davesshawns Date: Tue, 6 Oct 2026 17:24:18 +0100 Subject: [PATCH 2/2] fix: scope refunded disputes by user and admin --- src/routes/refunds.test.ts | 326 ++++++++++++++++++++++++++++++- src/routes/refunds.ts | 33 +++- src/services/refundsCacheWarm.ts | 2 +- 3 files changed, 355 insertions(+), 6 deletions(-) diff --git a/src/routes/refunds.test.ts b/src/routes/refunds.test.ts index a2f68fec..b7a5afb7 100644 --- a/src/routes/refunds.test.ts +++ b/src/routes/refunds.test.ts @@ -287,6 +287,7 @@ describe('POST /api/refunds', () => { describe('GET /api/refunds', () => { beforeEach(() => { clearRefundStore(); + process.env.ADMIN_API_KEY = 'test-admin-key'; }); it('200 - returns empty array when no requests exist', async () => { @@ -487,6 +488,162 @@ describe('GET /api/refunds', () => { expect(res.body.meta.limit).toBe(2); expect(res.body.meta.offset).toBe(1); }); + + // --------------------------------------------------------------------------- + // Issue #1256: user/admin REFUNDED dispute access & cache isolation + // --------------------------------------------------------------------------- + + it('200 - admin sees all REFUNDED disputes', async () => { + const app = createTestApp(); + + // Create refunds for two different developers + await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-1') + .send(validBody); + + const r2 = await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-2') + .send({ usageEventId: '123e4567-e89b-12d3-a456-426614174002', reason: 'Other developer refund request', amountUsdc: '25.00' }); + + // Resolve both as REFUNDED + const store = getRefundStore(); + const r1 = store.get(r2.body.data.id); + if (r1) { + r1.status = 'REFUNDED'; + r1.resolvedAt = new Date(); + r1.resolvedBy = 'admin-1'; + } + if (r2) { + r2.status = 'REFUNDED'; + r2.resolvedAt = new Date(); + r2.resolvedBy = 'admin-1'; + } + + // Admin sees all REFUNDED disputes + const res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1') + .set('x-admin-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta.total).toBe(2); + res.body.data.forEach((d: { developerId: string }) => { + expect(d.status).toBe('REFUNDED'); + }); + }); + + it('200 - user sees only their own REFUNDED disputes', async () => { + const app = createTestApp(); + + // Create refunds for two different developers + await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-1') + .send(validBody); + + const r2Create = await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-2') + .send({ usageEventId: '123e4567-e89b-12d3-a456-426614174002', reason: 'Other developer refund request', amountUsdc: '25.00' }); + + // Resolve both as REFUNDED + const store = getRefundStore(); + const r1 = store.get(r2Create.body.data.id); + if (r1) { + r1.status = 'REFUNDED'; + r1.resolvedAt = new Date(); + r1.resolvedBy = 'admin-1'; + } + const r2 = store.get(r2Create.body.data.id); + if (r2) { + r2.status = 'REFUNDED'; + r2.resolvedAt = new Date(); + r2.resolvedBy = 'admin-1'; + } + + // User sees only their own REFUNDED disputes + const res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe('dev-1'); + expect(res.body.data[0].status).toBe('REFUNDED'); + expect(res.body.meta.total).toBe(1); + }); + + it('cache scopes are isolated - user and admin caches do not interfere', async () => { + const app = createTestApp(); + + // Create a refund as dev-1 and resolve as REFUNDED + await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-1') + .send(validBody); + + const store = getRefundStore(); + const created = store.values().next().value; + if (created) { + created.status = 'REFUNDED'; + created.resolvedAt = new Date(); + created.resolvedBy = 'admin-1'; + } + + // User dev-1 sees their own REFUNDED dispute + let res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe('dev-1'); + + // Admin sees all REFUNDED disputes + res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1') + .set('x-admin-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe('dev-1'); + + // Create a refund as dev-2 and resolve as REFUNDED + await request(app) + .post('/api/refunds') + .set('x-user-id', 'dev-2') + .send({ usageEventId: '123e4567-e89b-12d3-a456-426614174003', reason: 'Dev-2 refund', amountUsdc: '30.00' }); + + const store2 = getRefundStore(); + const created2 = store2.values().next().value; + if (created2) { + created2.status = 'REFUNDED'; + created2.resolvedAt = new Date(); + created2.resolvedBy = 'admin-1'; + } + + // User dev-1 should still see only their own + res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe('dev-1'); + + // Admin should see both REFUNDED disputes + res = await request(app) + .get('/api/refunds') + .set('x-user-id', 'dev-1') + .set('x-admin-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); }); // --------------------------------------------------------------------------- @@ -963,4 +1120,171 @@ describe('Tracing spans for /api/refunds', () => { const spans = getSpans(); expect(spans[0].ended).toBe(true); }); -}); \ No newline at end of file +}); + // --------------------------------------------------------------------------- + // Issue #1256: user/admin REFUNDED dispute access & cache isolation + // --------------------------------------------------------------------------- + + describe("Issue #1256: REFUNDED dispute access & cache isolation", () => { + beforeEach(() => { + clearRefundStore(); + }); + + it("200 - admin sees all REFUNDED disputes", async () => { + const app = createTestApp(); + + // Create refunds for two different developers + await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-1") + .send(validBody); + + const r2Create = await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-2") + .send({ usageEventId: "123e4567-e89b-12d3-a456-426614174002", reason: "Other developer refund request", amountUsdc: "25.00" }); + + // Resolve both as REFUNDED + const store = getRefundStore(); + const dev1Id = store.keys().next().value; + const r1 = store.get(dev1Id); + if (r1) { + r1.status = "REFUNDED"; + r1.resolvedAt = new Date(); + r1.resolvedBy = "admin-1"; + } + const r2 = store.get(r2Create.body.data.id); + if (r2) { + r2.status = "REFUNDED"; + r2.resolvedAt = new Date(); + r2.resolvedBy = "admin-1"; + } + + // Admin sees all REFUNDED disputes + const res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1") + .set("x-admin-api-key", "test-admin-key"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta.total).toBe(2); + res.body.data.forEach((d: { status: string }) => { + expect(d.status).toBe("REFUNDED"); + }); + }); + + it("200 - user sees only their own REFUNDED disputes", async () => { + const app = createTestApp(); + + // Create refunds for two different developers + await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-1") + .send(validBody); + + const r2Create = await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-2") + .send({ usageEventId: "123e4567-e89b-12d3-a456-426614174002", reason: "Other developer refund request", amountUsdc: "25.00" }); + + // Resolve both as REFUNDED + const store = getRefundStore(); + const dev1Id = store.keys().next().value; + const r1 = store.get(dev1Id); + if (r1) { + r1.status = "REFUNDED"; + r1.resolvedAt = new Date(); + r1.resolvedBy = "admin-1"; + } + const r2 = store.get(r2Create.body.data.id); + if (r2) { + r2.status = "REFUNDED"; + r2.resolvedAt = new Date(); + r2.resolvedBy = "admin-1"; + } + + // User sees only their own REFUNDED disputes + const res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe("dev-1"); + expect(res.body.data[0].status).toBe("REFUNDED"); + expect(res.body.meta.total).toBe(1); + }); + + it("cache scopes are isolated - user and admin caches do not interfere", async () => { + const app = createTestApp(); + + // Create a refund as dev-1 and resolve as REFUNDED + await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-1") + .send(validBody); + + const store = getRefundStore(); + const dev1Id = store.keys().next().value; + const r1 = store.get(dev1Id); + if (r1) { + r1.status = "REFUNDED"; + r1.resolvedAt = new Date(); + r1.resolvedBy = "admin-1"; + } + + // User dev-1 sees their own REFUNDED dispute + let res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe("dev-1"); + + // Admin sees all REFUNDED disputes + res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1") + .set("x-admin-api-key", "test-admin-key"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe("dev-1"); + + // Create a refund as dev-2 and resolve as REFUNDED + await request(app) + .post("/api/refunds") + .set("x-user-id", "dev-2") + .send({ usageEventId: "123e4567-e89b-12d3-a456-426614174003", reason: "Dev-2 refund", amountUsdc: "30.00" }); + + const store2 = getRefundStore(); + const dev2Id = store2.keys().next().value; + const r2 = store2.get(dev2Id); + if (r2) { + r2.status = "REFUNDED"; + r2.resolvedAt = new Date(); + r2.resolvedBy = "admin-1"; + } + + // User dev-1 should still see only their own + res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].developerId).toBe("dev-1"); + + // Admin should see both REFUNDED disputes + res = await request(app) + .get("/api/refunds") + .set("x-user-id", "dev-1") + .set("x-admin-api-key", "test-admin-key"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + +}); diff --git a/src/routes/refunds.ts b/src/routes/refunds.ts index 0d7965eb..5eef8300 100644 --- a/src/routes/refunds.ts +++ b/src/routes/refunds.ts @@ -2,9 +2,16 @@ import { Router } from 'express'; import { defaultDisputeService } from '../services/disputeService.js'; import { refundsCache } from '../services/refundsCacheWarm.js'; import { successEnvelope, errorEnvelope, getRequestId } from '../lib/envelope.js'; +import type { ResponseMeta } from '../types/ResponseEnvelope.js'; const router = Router(); +function listRefunds(requestId: string, refunds: any, statusFilter?: string): any { + const total = refunds.length; + const meta: ResponseMeta = { total }; + return successEnvelope(refunds, requestId, meta); +} + function authenticateUser(req: Request): string | null { const userId = req.header('x-user-id'); if (userId) return userId.trim(); @@ -117,11 +124,20 @@ router.post('/', (req, res) => { router.get('/', (req, res) => { const requestId = getRequestId(req); + const statusFilter = (req.query.status as string) || undefined; + + if (statusFilter && !['pending', 'approved', 'rejected'].includes(statusFilter)) { + res.status(400).json(errorEnvelope('VALIDATION_ERROR', 'Invalid status query param', requestId)); + return; + } if (authenticateAdmin(req)) { const cached = refundsCache.get('admin:all'); if (cached !== undefined) { - res.json(successEnvelope(cached, requestId)); + const filtered = statusFilter + ? cached.filter((d: any) => d.status === statusFilter) + : cached; + res.json(listRefunds(requestId, filtered, statusFilter)); return; } @@ -129,7 +145,10 @@ router.get('/', (req, res) => { const refunds = all.filter((d) => d.status === 'REFUNDED'); refundsCache.set('admin:all', refunds); - res.json(successEnvelope(refunds, requestId)); + const filtered = statusFilter + ? refunds.filter((d: any) => d.status === statusFilter) + : refunds; + res.json(listRefunds(requestId, filtered, statusFilter)); return; } @@ -142,7 +161,10 @@ router.get('/', (req, res) => { const cacheKey = `user:${userId}`; const cached = refundsCache.get(cacheKey); if (cached !== undefined) { - res.json(successEnvelope(cached, requestId)); + const filtered = statusFilter + ? cached.filter((d: any) => d.status === statusFilter) + : cached; + res.json(listRefunds(requestId, filtered, statusFilter)); return; } @@ -150,7 +172,10 @@ router.get('/', (req, res) => { const refunds = all.filter((d) => d.status === 'REFUNDED' && d.opened_by === userId); refundsCache.set(cacheKey, refunds); - res.json(successEnvelope(refunds, requestId)); + const filtered = statusFilter + ? refunds.filter((d: any) => d.status === statusFilter) + : refunds; + res.json(listRefunds(requestId, filtered, statusFilter)); }); export function getRefundStore() { diff --git a/src/services/refundsCacheWarm.ts b/src/services/refundsCacheWarm.ts index 650e72da..8901def5 100644 --- a/src/services/refundsCacheWarm.ts +++ b/src/services/refundsCacheWarm.ts @@ -75,7 +75,7 @@ export async function warmupRefundsCache( ), ]); - refundsCache.set('all', result); + refundsCache.set('admin:all', result); const durationMs = Date.now() - started; logger.info(`[refundsCache] warmup completed in ${durationMs}ms — 1 entry loaded`);