Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/USER_PROFILE_PREFERENCES_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ Updates all user preferences.
- `digestFrequency`: must be one of `daily`, `weekly`, `monthly`, `never`
- `theme`: must be one of `light`, `dark`, `system`
- `language`: must be a valid BCP 47 language code
- `currencyPreference`: must be 3-character currency code
- `currencyPreference`: must be a valid ISO 4217 currency code (exactly 3 uppercase letters, e.g. `USD`, `EUR`, `GBP`)

**Response (200):**
```json
Expand Down Expand Up @@ -802,8 +802,9 @@ Checks the status of a specific export request.
- `pending`: Waiting to be processed
- `processing`: Currently generating export file
- `ready`: Ready for download
- `expired`: Download link expired
- `failed`: Export failed
- `expired`: Download link TTL elapsed — the file is no longer available
- `failed`: Export generation failed
- `cancelled`: User explicitly cancelled the request before it completed

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add 'cancelled' value to DataExportRequestStatus enum.
-- This separates user-initiated cancellation from TTL-based link expiry ('expired').
ALTER TYPE "DataExportRequestStatus" ADD VALUE 'cancelled';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum DataExportRequestStatus {
ready
expired
failed
cancelled
}

// Enum for curator verification status
Expand Down
10 changes: 6 additions & 4 deletions src/controllers/DataExportController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default class extends BaseController {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
},
status: { not: 'expired' },
status: { notIn: ['expired', 'cancelled'] },
},
});

Expand Down Expand Up @@ -137,7 +137,7 @@ export default class extends BaseController {
message: 'Export requests retrieved',
code: 200,
data: requests,
pagination: meta(total, requests.length),
meta: { pagination: meta(total, requests.length) },
});
} catch (error) {
throw error;
Expand Down Expand Up @@ -217,14 +217,16 @@ export default class extends BaseController {

RequestError.assertFound(exportRequest, 'Export request not found', 404);
RequestError.abortIf(
exportRequest.status === 'ready' || exportRequest.status === 'expired',
exportRequest.status === 'ready' ||
exportRequest.status === 'expired' ||
exportRequest.status === 'cancelled',
'Cannot cancel this export request',
400,
);

const updated = await prisma.dataExportRequest.update({
where: { id: requestId },
data: { status: 'expired' },
data: { status: 'cancelled' },
});

await logAuditEvent(userId, 'DATA_EXPORT', {
Expand Down
53 changes: 50 additions & 3 deletions src/controllers/__tests__/data-export-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ describe('Data Export & Account Deletion — HTTP integration', () => {
expect(res.body.status).toBe('success');
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(2);
expect(res.body.pagination).toBeDefined();
expect(typeof res.body.pagination.total).toBe('number');
expect(res.body.meta.pagination).toBeDefined();
expect(typeof res.body.meta.pagination.total).toBe('number');
});

it('does not return requests belonging to other users', async () => {
Expand Down Expand Up @@ -371,7 +371,7 @@ describe('Data Export & Account Deletion — HTTP integration', () => {
.set(authHeaders(owner.token));

expect(res.status).toBe(200);
expect(res.body.data.status).toBe('expired');
expect(res.body.data.status).toBe('cancelled');
});

it('cancels a processing export and returns 200', async () => {
Expand Down Expand Up @@ -415,6 +415,53 @@ describe('Data Export & Account Deletion — HTTP integration', () => {
expect(res.status).toBe(400);
});

it('returns 400 when trying to cancel an already-cancelled request', async () => {
const exportRequest = await prisma.dataExportRequest.create({
data: { userId: owner.user.id, format: 'json', status: 'cancelled' },
});

const res = await request(app)
.post(`/api/data-export/${exportRequest.id}/cancel`)
.set(authHeaders(owner.token));

expect(res.status).toBe(400);
});

it('cancelled status is distinct from expired — expired status is not set on cancellation', async () => {
const exportRequest = await prisma.dataExportRequest.create({
data: { userId: owner.user.id, format: 'json', status: 'pending' },
});

await request(app)
.post(`/api/data-export/${exportRequest.id}/cancel`)
.set(authHeaders(owner.token))
.expect(200);

const record = await prisma.dataExportRequest.findUnique({
where: { id: exportRequest.id },
});
expect(record!.status).toBe('cancelled');
expect(record!.status).not.toBe('expired');
});

it('allows a new export request after cancelling one', async () => {
const exportRequest = await prisma.dataExportRequest.create({
data: { userId: owner.user.id, format: 'json', status: 'pending' },
});

await request(app)
.post(`/api/data-export/${exportRequest.id}/cancel`)
.set(authHeaders(owner.token))
.expect(200);

const res = await request(app)
.post('/api/data-export/request')
.set(authHeaders(owner.token))
.send({ format: 'json' });

expect(res.status).toBe(201);
});

it('returns 404 when cancelling another user\'s request (authorization)', async () => {
const intruderRequest = await prisma.dataExportRequest.create({
data: { userId: intruder.user.id, format: 'json', status: 'pending' },
Expand Down
26 changes: 26 additions & 0 deletions src/controllers/__tests__/preferences.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,32 @@ describe('Preferences Controller', () => {
);
});

it('should accept valid ISO 4217 currency codes', async () => {
for (const code of ['USD', 'EUR', 'GBP', 'JPY', 'NGN']) {
const res = await request(app)
.post('/api/preferences')
.set('Authorization', `Bearer ${userToken}`)
.send({ currencyPreference: code })
.expect(202);

expect(res.body.data.currencyPreference).toBe(code);

await prisma.userPreferences.deleteMany({ where: { userId: testUserId } });
}
});

it('should reject invalid currency preference values', async () => {
for (const bad of ['usd', 'US', 'USDD', '123', 'abc', 'u s']) {
const res = await request(app)
.post('/api/preferences')
.set('Authorization', `Bearer ${userToken}`)
.send({ currencyPreference: bad })
.expect(422);

expect(res.body.errors).toHaveProperty('currencyPreference');
}
});

it('should validate preference values', async () => {
const res = await request(app)
.post('/api/preferences')
Expand Down
136 changes: 136 additions & 0 deletions src/controllers/__tests__/rate-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';

import app from '../../index';
import argon2 from 'argon2';
import { prisma } from 'src/db';
import { rateLimitStore } from 'src/middleware/rateLimiter';
import request from 'supertest';

let userToken: string;
let testUserId: string;

beforeAll(async () => {
const user = await prisma.user.create({
data: {
email: `test-ratelimit-${Date.now()}@example.com`,
password: await argon2.hash('password'),
firstName: 'Test',
lastName: 'User',
},
});

const res = await request(app).post('/api/auth/login').send({
email: user.email,
password: 'password',
});

userToken = res.body.token;
testUserId = res.body.data.id;
});

afterAll(async () => {
await prisma.userPreferences.deleteMany({ where: { userId: testUserId } });
await prisma.privacySettings.deleteMany({ where: { userId: testUserId } });
await prisma.accountLink.deleteMany({ where: { userId: testUserId } });
await prisma.user.delete({ where: { id: testUserId } });
});

afterEach(() => {
rateLimitStore.clear();
});

describe('Account Linking rate limit', () => {
it('happy path: includes rate-limit headers and succeeds', async () => {
const res = await request(app)
.post('/api/account-links')
.set('Authorization', `Bearer ${userToken}`)
.send({});

expect(res.status).not.toBe(429);
expect(res.headers['x-ratelimit-limit']).toBe('10');
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
expect(res.headers['x-ratelimit-reset']).toBeDefined();
});

it('returns 429 after 10 write requests in the same window', async () => {
for (let i = 0; i < 10; i++) {
await request(app)
.post('/api/account-links')
.set('Authorization', `Bearer ${userToken}`)
.send({});
}

const res = await request(app)
.post('/api/account-links')
.set('Authorization', `Bearer ${userToken}`)
.send({});

expect(res.status).toBe(429);
expect(res.headers['retry-after']).toBeDefined();
expect(res.body.success).toBe(false);
expect(res.body.retryAfter).toBeGreaterThan(0);
});

it('read routes are not subject to the account-linking write limit', async () => {
for (let i = 0; i < 10; i++) {
await request(app)
.post('/api/account-links')
.set('Authorization', `Bearer ${userToken}`)
.send({});
}

const res = await request(app)
.get('/api/account-links')
.set('Authorization', `Bearer ${userToken}`);

expect(res.status).not.toBe(429);
});
});

describe('Privacy updates rate limit', () => {
it('happy path: includes rate-limit headers and succeeds', async () => {
const res = await request(app)
.post('/api/privacy')
.set('Authorization', `Bearer ${userToken}`)
.send({});

expect(res.status).not.toBe(429);
expect(res.headers['x-ratelimit-limit']).toBe('20');
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
expect(res.headers['x-ratelimit-reset']).toBeDefined();
});

it('returns 429 after 20 write requests in the same window', async () => {
for (let i = 0; i < 20; i++) {
await request(app)
.post('/api/privacy')
.set('Authorization', `Bearer ${userToken}`)
.send({});
}

const res = await request(app)
.post('/api/privacy')
.set('Authorization', `Bearer ${userToken}`)
.send({});

expect(res.status).toBe(429);
expect(res.headers['retry-after']).toBeDefined();
expect(res.body.success).toBe(false);
expect(res.body.retryAfter).toBeGreaterThan(0);
});

it('read routes are not subject to the privacy write limit', async () => {
for (let i = 0; i < 20; i++) {
await request(app)
.post('/api/privacy')
.set('Authorization', `Bearer ${userToken}`)
.send({});
}

const res = await request(app)
.get('/api/privacy')
.set('Authorization', `Bearer ${userToken}`);

expect(res.status).not.toBe(429);
});
});
30 changes: 29 additions & 1 deletion src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { env } from '../utils/helpers';
* In-memory store for rate limiting
* Structure: { key: { count: number, resetTime: number } }
*/
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
export const rateLimitStore = new Map<string, { count: number; resetTime: number }>();

/**
* Set of authorized bypass tokens (hashed)
Expand Down Expand Up @@ -89,6 +89,16 @@ export const rateLimitConfigs = {
windowMs: 1 * 60 * 1000, // 1 minute
maxRequests: 30,
},
// Account linking operations: 10 per hour (documented contract)
accountLinking: {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10,
},
// Privacy update operations: 20 per hour (documented contract)
privacyUpdates: {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 20,
},
};

/**
Expand Down Expand Up @@ -247,6 +257,24 @@ export const getRequestRateLimitKey = (req: Request): string => {
return `ip-${req.ip}`;
};

/**
* Rate limiter for account-linking write operations (10 per hour per user)
*/
export const accountLinkingRateLimiter = createRateLimiter({
windowMs: rateLimitConfigs.accountLinking.windowMs,
maxRequests: rateLimitConfigs.accountLinking.maxRequests,
keyGenerator: getRequestRateLimitKey,
});

/**
* Rate limiter for privacy update operations (20 per hour per user)
*/
export const privacyRateLimiter = createRateLimiter({
windowMs: rateLimitConfigs.privacyUpdates.windowMs,
maxRequests: rateLimitConfigs.privacyUpdates.maxRequests,
keyGenerator: getRequestRateLimitKey,
});

/**
* Middleware to apply rate limiting with automatic cleanup
*/
Expand Down
7 changes: 4 additions & 3 deletions src/resources/DataExportRequestCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ export default class extends JsonResource {
* @returns this
*/
data () {
const data = Array.isArray(this.resource) ? this.resource : this.resource.data
const source = Array.isArray(this.resource) ? this.resource : this.resource.data

return {
data: data.map(
data: source.map(
(e: Resource) => new DataExportRequestResource(this.request, this.response, e).data()
)
),
pagination: Array.isArray(this.resource) ? undefined : this.resource.pagination,
}
}
}
Loading
Loading