diff --git a/docs/USER_PROFILE_PREFERENCES_API.md b/docs/USER_PROFILE_PREFERENCES_API.md index d5f42ad..5fc45ad 100644 --- a/docs/USER_PROFILE_PREFERENCES_API.md +++ b/docs/USER_PROFILE_PREFERENCES_API.md @@ -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 @@ -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 --- diff --git a/prisma/migrations/20260428000000_add_cancelled_export_status/migration.sql b/prisma/migrations/20260428000000_add_cancelled_export_status/migration.sql new file mode 100644 index 0000000..2c3346c --- /dev/null +++ b/prisma/migrations/20260428000000_add_cancelled_export_status/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0fa017..53f6c26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ enum DataExportRequestStatus { ready expired failed + cancelled } // Enum for curator verification status diff --git a/src/controllers/DataExportController.ts b/src/controllers/DataExportController.ts index ae547fd..0618489 100644 --- a/src/controllers/DataExportController.ts +++ b/src/controllers/DataExportController.ts @@ -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'] }, }, }); @@ -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; @@ -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', { diff --git a/src/controllers/__tests__/data-export-http.test.ts b/src/controllers/__tests__/data-export-http.test.ts index a1a0429..4cb74be 100644 --- a/src/controllers/__tests__/data-export-http.test.ts +++ b/src/controllers/__tests__/data-export-http.test.ts @@ -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 () => { @@ -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 () => { @@ -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' }, diff --git a/src/controllers/__tests__/preferences.controller.test.ts b/src/controllers/__tests__/preferences.controller.test.ts index b6ad067..6b57b0d 100644 --- a/src/controllers/__tests__/preferences.controller.test.ts +++ b/src/controllers/__tests__/preferences.controller.test.ts @@ -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') diff --git a/src/controllers/__tests__/rate-limits.test.ts b/src/controllers/__tests__/rate-limits.test.ts new file mode 100644 index 0000000..57a7cf1 --- /dev/null +++ b/src/controllers/__tests__/rate-limits.test.ts @@ -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); + }); +}); diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 785c768..517a73a 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -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(); +export const rateLimitStore = new Map(); /** * Set of authorized bypass tokens (hashed) @@ -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, + }, }; /** @@ -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 */ diff --git a/src/resources/DataExportRequestCollection.ts b/src/resources/DataExportRequestCollection.ts index 6a3bd87..47d99e1 100644 --- a/src/resources/DataExportRequestCollection.ts +++ b/src/resources/DataExportRequestCollection.ts @@ -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, } } } diff --git a/src/routes/api.ts b/src/routes/api.ts index 4798b24..8e047dd 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -10,6 +10,7 @@ import ProfileController from 'src/controllers/ProfileController'; import ReviewController from "src/controllers/ReviewController"; import SearchController from "src/controllers/SearchController"; import { authenticateOptionalToken, authenticateToken } from "src/utils/helpers"; +import { accountLinkingRateLimiter, privacyRateLimiter } from "src/middleware/rateLimiter"; const router = Router(); const reviewController = new ReviewController(); @@ -60,23 +61,20 @@ router.post('/preferences/notifications', authenticateToken, new PreferencesCont router.post('/preferences/two-factor/toggle', authenticateToken, new PreferencesController().toggleTwoFactor); router.post('/preferences/reset', authenticateToken, new PreferencesController().resetPreferences); -// Privacy Settings routes -router.get('/privacy', authenticateToken, new PrivacySettingsController().getPrivacySettings); -router.post('/privacy', authenticateToken, new PrivacySettingsController().updatePrivacySettings); -router.post('/privacy/visibility', authenticateToken, new PrivacySettingsController().updateProfileVisibility); -router.post('/privacy/block', authenticateToken, new PrivacySettingsController().blockUser); -router.post('/privacy/unblock', authenticateToken, new PrivacySettingsController().unblockUser); -router.get('/privacy/blocklist', authenticateToken, new PrivacySettingsController().getBlockList); -router.post('/privacy/retention', authenticateToken, new PrivacySettingsController().updateDataRetention); - -// Restricted list management -router.post('/privacy/restrict', authenticateToken, new PrivacySettingsController().addToRestrictedList); -router.post('/privacy/unrestrict', authenticateToken, new PrivacySettingsController().removeFromRestrictedList); -router.get('/privacy/restricted-list', authenticateToken, new PrivacySettingsController().getRestrictedList); - -// Custom privacy rules management -router.post('/privacy/custom-rules', authenticateToken, new PrivacySettingsController().updateCustomPrivacyRules); -router.get('/privacy/custom-rules/default', authenticateToken, new PrivacySettingsController().getDefaultCustomRules); +// Privacy Settings routes (writes are limited to 20 per hour per user) +const privacyController = new PrivacySettingsController(); +router.get('/privacy', authenticateToken, privacyController.getPrivacySettings); +router.post('/privacy', authenticateToken, privacyRateLimiter, privacyController.updatePrivacySettings); +router.post('/privacy/visibility', authenticateToken, privacyRateLimiter, privacyController.updateProfileVisibility); +router.post('/privacy/block', authenticateToken, privacyRateLimiter, privacyController.blockUser); +router.post('/privacy/unblock', authenticateToken, privacyRateLimiter, privacyController.unblockUser); +router.get('/privacy/blocklist', authenticateToken, privacyController.getBlockList); +router.post('/privacy/retention', authenticateToken, privacyRateLimiter, privacyController.updateDataRetention); +router.post('/privacy/restrict', authenticateToken, privacyRateLimiter, privacyController.addToRestrictedList); +router.post('/privacy/unrestrict', authenticateToken, privacyRateLimiter, privacyController.removeFromRestrictedList); +router.get('/privacy/restricted-list', authenticateToken, privacyController.getRestrictedList); +router.post('/privacy/custom-rules', authenticateToken, privacyRateLimiter, privacyController.updateCustomPrivacyRules); +router.get('/privacy/custom-rules/default', authenticateToken, privacyController.getDefaultCustomRules); // Friendship management routes router.get('/friends', authenticateToken, new FriendshipController().getFriends); @@ -90,13 +88,14 @@ router.post('/friends/block', authenticateToken, new FriendshipController().bloc router.post('/friends/unblock', authenticateToken, new FriendshipController().unblockUser); router.get('/friends/blocked', authenticateToken, new FriendshipController().getBlockedUsers); -// Account Linking routes -router.get('/account-links', authenticateToken, new AccountLinkingController().getLinkedAccounts); -router.post('/account-links', authenticateToken, new AccountLinkingController().linkAccount); -router.post('/account-links/check-availability', authenticateToken, new AccountLinkingController().checkAvailability); -router.post('/account-links/verify', authenticateToken, new AccountLinkingController().verifyAccountLink); -router.get('/account-links/:provider', authenticateToken, new AccountLinkingController().checkProviderLinked); -router.delete('/account-links/:provider', authenticateToken, new AccountLinkingController().unlinkAccount); +// Account Linking routes (writes are limited to 10 per hour per user) +const accountLinkingController = new AccountLinkingController(); +router.get('/account-links', authenticateToken, accountLinkingController.getLinkedAccounts); +router.post('/account-links', authenticateToken, accountLinkingRateLimiter, accountLinkingController.linkAccount); +router.post('/account-links/check-availability', authenticateToken, accountLinkingRateLimiter, accountLinkingController.checkAvailability); +router.post('/account-links/verify', authenticateToken, accountLinkingRateLimiter, accountLinkingController.verifyAccountLink); +router.get('/account-links/:provider', authenticateToken, accountLinkingController.checkProviderLinked); +router.delete('/account-links/:provider', authenticateToken, accountLinkingRateLimiter, accountLinkingController.unlinkAccount); // Data Export routes (GDPR compliance) router.post('/data-export/request', authenticateToken, new DataExportController().requestDataExport); diff --git a/src/utils/__tests__/data-export.controller.test.ts b/src/utils/__tests__/data-export.controller.test.ts index f7e2454..ba2698f 100644 --- a/src/utils/__tests__/data-export.controller.test.ts +++ b/src/utils/__tests__/data-export.controller.test.ts @@ -134,6 +134,42 @@ describe('Data Export Controller', () => { }); }); + describe('getExportRequests', () => { + it('returns meta.pagination matching the documented contract', async () => { + await prisma.dataExportRequest.createMany({ + data: [ + { userId: testUserId, format: 'json', status: 'pending' }, + { userId: testUserId, format: 'csv', status: 'pending' }, + ], + }); + + const res = await request(app) + .get('/api/data-export/requests') + .set({ 'Authorization': `Bearer ${userToken}`, 'Advance-Token': env('JWT_SECRET') }) + .expect(200); + + expect(res.body.meta).toBeDefined(); + expect(res.body.meta.pagination).toMatchObject({ + perPage: expect.any(Number), + total: 2, + from: 1, + to: 2, + }); + expect(res.body).not.toHaveProperty('pagination'); + }); + + it('returns an empty data array with zeroed pagination when no requests exist', async () => { + const res = await request(app) + .get('/api/data-export/requests') + .set({ 'Authorization': `Bearer ${userToken}`, 'Advance-Token': env('JWT_SECRET') }) + .expect(200); + + expect(res.body.data).toEqual([]); + expect(res.body.meta.pagination.total).toBe(0); + expect(res.body.meta.pagination.from).toBe(0); + }); + }); + describe('downloadDataExport', () => { it('should download completed export', async () => { const exportRequest = await prisma.dataExportRequest.create({ diff --git a/src/utils/profileValidators.ts b/src/utils/profileValidators.ts index 378031c..be455b2 100644 --- a/src/utils/profileValidators.ts +++ b/src/utils/profileValidators.ts @@ -49,7 +49,7 @@ export const preferencesValidationRules: InitialRules = { digestFrequency: ['string', 'in:daily,weekly,monthly,never'], theme: ['string', 'in:light,dark,system'], language: ['string', 'bcp47'], - currencyPreference: ['string', 'min:3,max:3'], + currencyPreference: ['string', 'iso4217'], twoFactorEnabled: ['boolean'], dataCollectionConsent: ['boolean'], analyticsTracking: ['boolean'], diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 76e3b87..8b10a4f 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -45,11 +45,13 @@ setTranslationObject({ exists: 'The selected :attribute does not exist.', bcp47: 'The :attribute must be a valid BCP 47 language code.', ianaTimezone: 'The :attribute must be a valid IANA time zone identifier.', + iso4217: 'The :attribute must be a valid ISO 4217 currency code (3 uppercase letters, e.g. USD).', } }); export const BCP47_REGEX = /^[a-z]{2,3}(?:-[a-z]{3})?(?:-(?:[a-z]{4}|[a-z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*$/i; export const IANA_TIMEZONE_REGEX = /^[A-Za-z][A-Za-z0-9_\/-]*$/; +export const ISO4217_REGEX = /^[A-Z]{3}$/; register('unique', async function (value, parameters, attribute) { const [modelName, field, except, exceptField] = parameters ?? []; @@ -69,6 +71,14 @@ register('bcp47', function (value) { return BCP47_REGEX.test(value); }); +register('iso4217', function (value) { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + + return ISO4217_REGEX.test(value); +}); + register('ianaTimezone', function (value) { if (typeof value !== 'string' || !value.trim()) { return false; diff --git a/testSetup.ts b/testSetup.ts index 612263c..1388c44 100644 --- a/testSetup.ts +++ b/testSetup.ts @@ -1 +1,9 @@ import 'tsconfig-paths/register'; +import { afterEach } from 'vitest'; +import { rateLimitStore } from 'src/middleware/rateLimiter'; + +// Reset the in-memory rate-limit store after every test so feature-specific +// limiters (account-linking 10/hr, privacy 20/hr) don't bleed across test cases. +afterEach(() => { + rateLimitStore.clear(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7f57e56..0e0caef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ test: { retry: 2, root: './', + setupFiles: ['./testSetup.ts'], passWithNoTests: true, environment: 'node', include: ['**/__tests__/**/*.{test,spec}.?(c|m)[jt]s?(x)'],