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
18 changes: 12 additions & 6 deletions src/controllers/DataExportController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -336,21 +340,23 @@ 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 } });

// 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({
Expand Down
4 changes: 2 additions & 2 deletions src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
56 changes: 56 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
133 changes: 133 additions & 0 deletions tests/integration/data-export.test.ts
Original file line number Diff line number Diff line change
@@ -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<Request>;
let mockRes: Partial<Response>;

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');
});
});
Loading