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 9e09e5f8..5eef8300 100644 --- a/src/routes/refunds.ts +++ b/src/routes/refunds.ts @@ -1,23 +1,192 @@ 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'; +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(); + + 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'); + 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) { + const filtered = statusFilter + ? cached.filter((d: any) => d.status === statusFilter) + : cached; + res.json(listRefunds(requestId, filtered, statusFilter)); + return; + } + + const all = defaultDisputeService.listAll(); + const refunds = all.filter((d) => d.status === 'REFUNDED'); + refundsCache.set('admin:all', refunds); + + const filtered = statusFilter + ? refunds.filter((d: any) => d.status === statusFilter) + : refunds; + res.json(listRefunds(requestId, filtered, statusFilter)); + 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)); + const filtered = statusFilter + ? cached.filter((d: any) => d.status === statusFilter) + : cached; + res.json(listRefunds(requestId, filtered, statusFilter)); 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)); + const filtered = statusFilter + ? refunds.filter((d: any) => d.status === statusFilter) + : refunds; + res.json(listRefunds(requestId, filtered, statusFilter)); }); -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 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`);