diff --git a/src/controllers/DataExportController.ts b/src/controllers/DataExportController.ts index 4df31d9..ae547fd 100644 --- a/src/controllers/DataExportController.ts +++ b/src/controllers/DataExportController.ts @@ -327,7 +327,11 @@ export default class extends BaseController { const userId = req.user?.id; const { token } = req.body; - RequestError.assertFound(userId, 'Unauthorized', 401); + // Enforce explicit authorization rules + if (!token) { + // In-app flow: must be authenticated + RequestError.assertFound(userId, 'Unauthorized', 401); + } // Resolve record by token (email-link flow) or userId (in-app flow) const pending = token @@ -336,8 +340,10 @@ export default class extends BaseController { RequestError.assertFound(pending, 'No pending deletion request found', 404); - // Ensure the record belongs to the calling user - RequestError.abortIf(pending.userId !== userId, 'Forbidden', 403); + // If in-app flow or if auth provided with token flow, ensure the record belongs to the calling user + if (userId) { + RequestError.abortIf(pending.userId !== userId, 'Forbidden', 403); + } // Remove the pending-deletion record await prisma.pendingDeletion.delete({ where: { userId: pending.userId } }); @@ -345,12 +351,12 @@ export default class extends BaseController { // Queue cancellation confirmation email // e.g. emailQueue.enqueue({ type: 'DELETION_CANCELLED', userId: pending.userId }) - await logAuditEvent(userId, 'DATA_DELETE', { + await logAuditEvent(pending.userId, 'DATA_DELETE', { req, entityType: 'User', - entityId: userId, + entityId: pending.userId, statusCode: 200, - metadata: { action: 'cancel_deletion' }, + metadata: { action: 'cancel_deletion', method: token ? 'email_link' : 'in_app' }, }); return res.json({ diff --git a/src/routes/api.ts b/src/routes/api.ts index 393fa32..4798b24 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,7 +9,7 @@ import PrivacySettingsController from 'src/controllers/PrivacySettingsController import ProfileController from 'src/controllers/ProfileController'; import ReviewController from "src/controllers/ReviewController"; import SearchController from "src/controllers/SearchController"; -import { authenticateToken } from "src/utils/helpers"; +import { authenticateOptionalToken, authenticateToken } from "src/utils/helpers"; const router = Router(); const reviewController = new ReviewController(); @@ -105,6 +105,6 @@ router.get('/data-export/:requestId/status', authenticateToken, new DataExportCo router.get('/data-export/:requestId/download', authenticateToken, new DataExportController().downloadExport); router.post('/data-export/:requestId/cancel', authenticateToken, new DataExportController().cancelExport); router.post('/account/deletion-request', authenticateToken, new DataExportController().requestAccountDeletion); -router.post('/account/cancel-deletion', authenticateToken, new DataExportController().cancelAccountDeletion); +router.post('/account/cancel-deletion', authenticateOptionalToken, new DataExportController().cancelAccountDeletion); export default router; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 51df1da..7c07ed5 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -125,6 +125,62 @@ export const authenticateToken = ( } }; +export const authenticateOptionalToken = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; + + if (!token) { + return next(); + } + + try { + jwt.verify( + token!, + env("JWT_SECRET", ""), + async (err: any, jwtPayload: any) => { + if (err) { + return next(); + } + + const accessToken = await prisma.personalAccessToken.findFirst({ + where: { token }, + include: { user: { include: { curator: true } } }, + }); + let user = accessToken?.user; + + // Test environment fallback: allow JWT-only auth without DB token lookup + if (!user && process.env.NODE_ENV === "test" && jwtPayload?.id) { + user = (await prisma.user.findUnique({ + where: { id: jwtPayload.id }, + include: { curator: true }, + })) as any; + } + + // Check if user exists and token is valid (with null-safe expiry check) + if ( + !user || + (!accessToken && process.env.NODE_ENV !== "test") || + (accessToken && + isPast(constructFrom(accessToken.expiresAt!, new Date()))) + ) { + return next(); + } + + req.user = user as never; + req.authToken = accessToken?.token; + + next(); + }, + ); + } catch (e) { + next(); + } +}; + /** * Read the .env file * diff --git a/tests/integration/data-export.test.ts b/tests/integration/data-export.test.ts new file mode 100644 index 0000000..a26b0cc --- /dev/null +++ b/tests/integration/data-export.test.ts @@ -0,0 +1,133 @@ +import { PrismaClient } from '@prisma/client'; +import { Request, Response } from 'express'; +import DataExportController from '../../src/controllers/DataExportController'; + +const prisma = new PrismaClient(); + +jest.mock('../../src/utils/auditLogger', () => ({ + logAuditEvent: jest.fn(), +})); + +describe('DataExportController - Cancel Account Deletion', () => { + let controller: DataExportController; + let mockReq: Partial; + let mockRes: Partial; + + beforeAll(async () => { + // Create a dummy user for the test + await prisma.user.upsert({ + where: { id: 'test-user-id' }, + update: {}, + create: { + id: 'test-user-id', + email: 'test@example.com', + username: 'testuser', + password: 'hashedpassword', + }, + }); + }); + + beforeEach(() => { + controller = new DataExportController(); + mockReq = { + body: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + }); + + afterEach(async () => { + await prisma.pendingDeletion.deleteMany({ + where: { userId: 'test-user-id' }, + }); + }); + + afterAll(async () => { + await prisma.user.delete({ + where: { id: 'test-user-id' }, + }); + await prisma.$disconnect(); + }); + + it('should successfully cancel deletion using email-link token (no auth required)', async () => { + const token = 'random-cancel-token-123'; + + await prisma.pendingDeletion.create({ + data: { + userId: 'test-user-id', + token, + scheduledAt: new Date(Date.now() + 86400000), + }, + }); + + mockReq.body = { token }; + // Notice no req.user is set, simulating unauthenticated state + + await controller.cancelAccountDeletion(mockReq as Request, mockRes as Response); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + message: 'Account deletion cancelled. Your account is safe.', + }) + ); + + const pending = await prisma.pendingDeletion.findUnique({ where: { token } }); + expect(pending).toBeNull(); + }); + + it('should successfully cancel deletion using in-app flow (requires auth)', async () => { + await prisma.pendingDeletion.create({ + data: { + userId: 'test-user-id', + token: 'another-token', + scheduledAt: new Date(Date.now() + 86400000), + }, + }); + + mockReq.user = { id: 'test-user-id' } as any; + mockReq.body = {}; // No token provided + + await controller.cancelAccountDeletion(mockReq as Request, mockRes as Response); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + message: 'Account deletion cancelled. Your account is safe.', + }) + ); + + const pending = await prisma.pendingDeletion.findUnique({ where: { userId: 'test-user-id' } }); + expect(pending).toBeNull(); + }); + + it('should fail in-app flow if not authenticated', async () => { + mockReq.body = {}; // No token provided + // No req.user set + + await expect(controller.cancelAccountDeletion(mockReq as Request, mockRes as Response)).rejects.toThrow('Unauthorized'); + }); + + it('should fail if token is invalid', async () => { + mockReq.body = { token: 'invalid-token' }; + + await expect(controller.cancelAccountDeletion(mockReq as Request, mockRes as Response)).rejects.toThrow('No pending deletion request found'); + }); + + it('should fail if authenticated user tries to cancel anothers deletion', async () => { + await prisma.pendingDeletion.create({ + data: { + userId: 'test-user-id', + token: 'token-abc', + scheduledAt: new Date(Date.now() + 86400000), + }, + }); + + mockReq.user = { id: 'other-user-id' } as any; + mockReq.body = { token: 'token-abc' }; + + await expect(controller.cancelAccountDeletion(mockReq as Request, mockRes as Response)).rejects.toThrow('Forbidden'); + }); +});