Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/backend/src/__tests__/analytics-export.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}, 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"');
});
});
70 changes: 70 additions & 0 deletions apps/backend/src/routes/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { date: string; platform: string; event_type: string; count: number }> = {};

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(','));
}
Comment on lines +118 to +121
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) => {
Expand Down