diff --git a/apps/backend/src/__tests__/analytics-export.test.ts b/apps/backend/src/__tests__/analytics-export.test.ts new file mode 100644 index 0000000..a442167 --- /dev/null +++ b/apps/backend/src/__tests__/analytics-export.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import Fastify from 'fastify'; +import { analyticsRoutes } from '../routes/analytics.js'; + +// Minimal mock of app.prisma and app.authenticate +function buildApp(prismaOverrides: Record = {}, authenticateReject = false) { + const app = Fastify(); + + (app as any).prisma = { + cardView: { + count: vi.fn(async () => 0), + findMany: prismaOverrides.cardViewFindMany ?? vi.fn(async () => []), + groupBy: vi.fn(async () => []), + }, + followLog: { + count: vi.fn(async () => 0), + findMany: prismaOverrides.followLogFindMany ?? vi.fn(async () => []), + groupBy: vi.fn(async () => []), + }, + }; + + (app as any).authenticate = async (request: any, reply: any) => { + if (authenticateReject) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + request.user = { id: 'user-1' }; + }; + + app.register(analyticsRoutes, { prefix: '/api/analytics' }); + return app; +} + +describe('GET /api/analytics/export', () => { + it('returns 401 when unauthenticated', async () => { + const app = buildApp({}, true); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(401); + }); + + it('returns 403 when querying another user data via userId param', async () => { + const app = buildApp(); + await app.ready(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/export?userId=other-user', + }); + expect(res.statusCode).toBe(403); + }); + + it('returns CSV with correct headers and structure', async () => { + const date = new Date('2025-01-15T10:00:00Z'); + const app = buildApp({ + cardViewFindMany: vi.fn(async () => [ + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'web' }, + { createdAt: date, source: 'web' }, + ]), + followLogFindMany: vi.fn(async () => [ + { createdAt: date, platform: 'github' }, + ]), + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/csv'); + expect(res.headers['content-disposition']).toContain('attachment'); + expect(res.headers['content-disposition']).toContain('devcard-analytics.csv'); + + const lines = res.body.split('\n'); + expect(lines[0]).toBe('date,platform,event_type,count'); + const dataLines = lines.slice(1); + expect(dataLines).toContain('2025-01-15,qr,card_view,3'); + expect(dataLines).toContain('2025-01-15,web,card_view,2'); + expect(dataLines).toContain('2025-01-15,github,follow,1'); + }); + + it('returns only header row when user has no data', async () => { + const app = buildApp(); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(200); + expect(res.body.trim()).toBe('date,platform,event_type,count'); + }); + + it('applies RFC 4180 CSV escaping for values containing commas', async () => { + const date = new Date('2025-03-01T00:00:00Z'); + const app = buildApp({ + cardViewFindMany: vi.fn(async () => [ + { createdAt: date, source: 'web,mobile' }, + ]), + followLogFindMany: vi.fn(async () => []), + }); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.body).toContain('"web,mobile"'); + }); +}); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb..e22701a 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -57,6 +57,76 @@ export async function analyticsRoutes(app: FastifyInstance) { }; }); + app.get('/export', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest<{ Querystring: { userId?: string } }>, reply: FastifyReply) => { + const requestingUserId = (request.user as any).id; + + // IDOR guard: if a userId query param is supplied and differs from the caller, reject. + if (request.query.userId && request.query.userId !== requestingUserId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + const userId = requestingUserId; + + // Use findMany + in-code day-level aggregation instead of groupBy on + // createdAt (a full timestamp), which would produce one group per row. + const [cardViews, followLogs] = await Promise.all([ + app.prisma.cardView.findMany({ + where: { ownerId: userId }, + select: { createdAt: true, source: true }, + }), + app.prisma.followLog.findMany({ + where: { followerId: userId, status: 'success' }, + select: { createdAt: true, platform: true }, + }), + ]); + + // Aggregate by date (day) + platform + event_type + const aggregated: Record = {}; + + for (const row of cardViews) { + const date = row.createdAt.toISOString().slice(0, 10); + const platform = row.source ?? 'unknown'; + const key = `${date}|${platform}|card_view`; + if (!aggregated[key]) { + aggregated[key] = { date, platform, event_type: 'card_view', count: 0 }; + } + aggregated[key].count += 1; + } + + for (const row of followLogs) { + const date = row.createdAt.toISOString().slice(0, 10); + const platform = row.platform ?? 'unknown'; + const key = `${date}|${platform}|follow`; + if (!aggregated[key]) { + aggregated[key] = { date, platform, event_type: 'follow', count: 0 }; + } + aggregated[key].count += 1; + } + + const rows = Object.values(aggregated).sort((a, b) => a.date.localeCompare(b.date)); + + // RFC 4180-compliant escaping: wrap cell in quotes if it contains comma, + // newline, or quote; double any embedded quotes. + function csvCell(value: string | number): string { + const s = String(value); + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; + } + + const csvLines = ['date,platform,event_type,count']; + for (const r of rows) { + csvLines.push([csvCell(r.date), csvCell(r.platform), csvCell(r.event_type), csvCell(r.count)].join(',')); + } + const csv = csvLines.join('\n'); + + reply + .header('Content-Type', 'text/csv') + .header('Content-Disposition', 'attachment; filename=devcard-analytics.csv') + .send(csv); + }); + app.get('/views', { preHandler: [app.authenticate], }, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => {