Skip to content

Commit 66e49cf

Browse files
committed
feat(api): implement hardened soft delete and automated cleanup worker
1 parent f3cd28f commit 66e49cf

13 files changed

Lines changed: 590 additions & 52 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use strict';
2+
3+
const mockFindOneAndUpdate = jest.fn();
4+
const mockFindOne = jest.fn();
5+
6+
const mockModel = {
7+
findOneAndUpdate: mockFindOneAndUpdate,
8+
};
9+
10+
jest.mock('@urbackend/common', () => ({
11+
Project: {
12+
findOne: mockFindOne,
13+
},
14+
getConnection: jest.fn().mockResolvedValue({}),
15+
getCompiledModel: jest.fn(() => mockModel),
16+
}));
17+
18+
const { deleteRow } = require('../controllers/project.controller');
19+
20+
function makeReq() {
21+
return {
22+
params: { projectId: 'proj_1', collectionName: 'posts', id: '507f1f77bcf86cd799439011' },
23+
user: { _id: 'user_1' }
24+
};
25+
}
26+
27+
function makeRes() {
28+
const res = {
29+
statusCode: null,
30+
body: null,
31+
status: jest.fn().mockReturnThis(),
32+
json: jest.fn().mockReturnThis(),
33+
};
34+
return res;
35+
}
36+
37+
describe('Soft Delete in dashboard project.controller', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
42+
test('deleteRow sets isDeleted: true instead of hard deleting', async () => {
43+
const req = makeReq();
44+
const res = makeRes();
45+
46+
// Mock project
47+
const project = {
48+
_id: 'proj_1',
49+
resources: { db: { isExternal: false } },
50+
collections: [{ name: 'posts', model: [] }],
51+
save: jest.fn().mockResolvedValue(true)
52+
};
53+
mockFindOne.mockResolvedValue(project);
54+
55+
// Mock document
56+
const doc = { _id: '507f1f77bcf86cd799439011', isDeleted: false };
57+
mockFindOneAndUpdate.mockReturnValue({
58+
lean: jest.fn().mockResolvedValue(doc)
59+
});
60+
61+
await deleteRow(req, res);
62+
63+
expect(mockFindOne).toHaveBeenCalled();
64+
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
65+
{ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } },
66+
expect.objectContaining({
67+
$set: expect.objectContaining({ isDeleted: true, deletedAt: expect.any(Date) })
68+
}),
69+
{ new: false }
70+
);
71+
72+
expect(res.json).toHaveBeenCalledWith({
73+
success: true,
74+
data: { id: '507f1f77bcf86cd799439011' },
75+
message: "Document deleted successfully."
76+
});
77+
78+
// Should not save project (to update databaseUsed) since it's a soft delete
79+
expect(project.save).not.toHaveBeenCalled();
80+
});
81+
82+
test('deleteRow returns 404 if document is already soft-deleted or not found', async () => {
83+
const req = makeReq();
84+
const res = makeRes();
85+
86+
// Mock project
87+
const project = {
88+
_id: 'proj_1',
89+
resources: { db: { isExternal: false } },
90+
collections: [{ name: 'posts', model: [] }],
91+
save: jest.fn().mockResolvedValue(true)
92+
};
93+
mockFindOne.mockResolvedValue(project);
94+
95+
mockFindOneAndUpdate.mockReturnValue({
96+
lean: jest.fn().mockResolvedValue(null)
97+
});
98+
99+
await deleteRow(req, res);
100+
101+
expect(res.status).toHaveBeenCalledWith(404);
102+
expect(res.json).toHaveBeenCalledWith({
103+
success: false,
104+
data: {},
105+
message: "Document not found."
106+
});
107+
});
108+
});

apps/dashboard-api/src/controllers/project.controller.js

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,10 @@ module.exports.insertData = async (req, res) => {
889889
.json({ error: "Collection configuration not found." });
890890
}
891891

892+
// Prevent manual injection of soft-delete fields
893+
delete incomingData.isDeleted;
894+
delete incomingData.deletedAt;
895+
892896
let docSize = 0;
893897
if (!project.resources.db.isExternal) {
894898
docSize = Buffer.byteLength(JSON.stringify(incomingData));
@@ -955,21 +959,25 @@ module.exports.deleteRow = async (req, res) => {
955959
project.resources.db.isExternal,
956960
);
957961

958-
const docToDelete = await Model.findById(id);
959-
if (!docToDelete) {
960-
return res.status(404).json({ error: "Document not found." });
961-
}
962-
963-
const docSize = Buffer.byteLength(JSON.stringify(docToDelete));
964-
965-
await Model.deleteOne({ _id: id });
962+
const result = await Model.findOneAndUpdate(
963+
{ _id: id, isDeleted: { $ne: true } },
964+
{
965+
$set: {
966+
isDeleted: true,
967+
deletedAt: new Date()
968+
}
969+
},
970+
{ new: false }
971+
).lean();
966972

967-
if (!project.resources.db.isExternal) {
968-
project.databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize);
969-
await project.save();
973+
if (!result) {
974+
return res.status(404).json({ success: false, data: {}, message: "Document not found." });
970975
}
971976

972-
res.json({ success: true, message: "Document deleted successfully" });
977+
// We don't decrement databaseUsed here because the document still occupies space.
978+
// It will be decremented during hard delete in the background worker.
979+
980+
res.json({ success: true, data: { id: result._id }, message: "Document deleted successfully." });
973981
} catch (err) {
974982
console.error("Delete Error:", err);
975983
res.status(500).json({ error: err.message });
@@ -1009,11 +1017,15 @@ module.exports.editRow = async (req, res) => {
10091017
});
10101018
}
10111019

1012-
const docToEdit = await Model.findById(id);
1020+
const docToEdit = await Model.findOne({ _id: id, isDeleted: { $ne: true } });
10131021
if (!docToEdit) {
10141022
return res.status(404).json({ error: "Document not found." });
10151023
}
10161024

1025+
// Prevent manual injection of soft-delete fields
1026+
delete req.body.isDeleted;
1027+
delete req.body.deletedAt;
1028+
10171029
const oldSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject()));
10181030

10191031
docToEdit.set(req.body);

apps/public-api/src/__tests__/aggregation.test.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ describe('aggregateData controller', () => {
5656
});
5757

5858
test('executes a valid aggregation pipeline', async () => {
59-
const req = makeReq();
59+
const req = makeReq({ query: {} });
6060
const res = makeRes();
6161

6262
await aggregateData(req, res);
6363

6464
expect(mockAggregate).toHaveBeenCalledWith([
65+
{ $match: { isDeleted: { $ne: true } } },
6566
{ $group: { _id: '$status', count: { $sum: 1 } } },
6667
]);
6768
expect(res.statusCode).toBe(200);
@@ -74,6 +75,7 @@ describe('aggregateData controller', () => {
7475

7576
test('prepends the RLS match stage when req.rlsFilter is set', async () => {
7677
const req = makeReq({
78+
query: {},
7779
rlsFilter: { userId: 'user_1' },
7880
body: {
7981
pipeline: [
@@ -87,13 +89,29 @@ describe('aggregateData controller', () => {
8789
await aggregateData(req, res);
8890

8991
expect(mockAggregate).toHaveBeenCalledWith([
90-
{ $match: { userId: 'user_1' } },
92+
{ $match: { userId: 'user_1', isDeleted: { $ne: true } } },
9193
{ $match: { status: 'published' } },
9294
{ $sort: { createdAt: -1 } },
9395
]);
9496
expect(res.statusCode).toBe(200);
9597
});
9698

99+
test('includes soft-deleted documents when include_deleted=true is passed', async () => {
100+
const req = makeReq({
101+
query: { include_deleted: 'true' },
102+
body: { pipeline: [{ $group: { _id: '$status', count: { $sum: 1 } } }] },
103+
});
104+
const res = makeRes();
105+
106+
await aggregateData(req, res);
107+
108+
expect(mockAggregate).toHaveBeenCalledWith([
109+
{ $match: {} }, // softDeleteFilter should be empty
110+
{ $group: { _id: '$status', count: { $sum: 1 } } },
111+
]);
112+
expect(res.statusCode).toBe(200);
113+
});
114+
97115
test('blocks write-capable aggregation stages', async () => {
98116
const req = makeReq({
99117
body: { pipeline: [{ $match: { status: 'published' } }, { $out: 'archive' }] },

apps/public-api/src/__tests__/data.controller.read.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('data.controller read RLS filters', () => {
117117
$and: [
118118
{ _id: '507f1f77bcf86cd799439011' },
119119
{ userId: 'user_1' },
120+
{ isDeleted: { $ne: true } }
120121
],
121122
});
122123
expect(res.json).toHaveBeenCalled();
@@ -194,4 +195,48 @@ describe('data.controller read RLS filters', () => {
194195
expect(res.json).toHaveBeenCalled();
195196
expect(res.status).not.toHaveBeenCalledWith(500);
196197
});
198+
199+
test('getSingleDoc excludes soft-deleted documents by default', async () => {
200+
const req = makeReq();
201+
const res = makeRes();
202+
203+
await getSingleDoc(req, res);
204+
205+
expect(mockFindOne).toHaveBeenCalled();
206+
const findOneArgs = mockFindOne.mock.calls[0][0];
207+
expect(findOneArgs.$and).toEqual(
208+
expect.arrayContaining([
209+
{ _id: '507f1f77bcf86cd799439011' },
210+
{ isDeleted: { $ne: true } }
211+
])
212+
);
213+
});
214+
215+
test('getSingleDoc includes soft-deleted documents when include_deleted=true is passed', async () => {
216+
const req = makeReq({
217+
query: { include_deleted: 'true' },
218+
rlsFilter: { ownerId: 'user_123' }
219+
});
220+
const res = makeRes();
221+
222+
await getSingleDoc(req, res);
223+
224+
expect(mockFindOne).toHaveBeenCalled();
225+
const findOneArgs = mockFindOne.mock.calls[0][0];
226+
227+
// Assert it contains the ID and RLS filter
228+
expect(findOneArgs.$and).toContainEqual({ _id: '507f1f77bcf86cd799439011' });
229+
expect(findOneArgs.$and).toContainEqual({ ownerId: 'user_123' });
230+
231+
// Assert it does NOT contain the soft delete filter
232+
expect(findOneArgs.$and).not.toContainEqual({ isDeleted: { $ne: true } });
233+
234+
// Assert no other filters are present (length should be 3 if softDeleteFilter was {}, but it's {} so it shouldn't add much, let's check exact elements)
235+
const softDeleteFilter = {}; // because include_deleted is true
236+
expect(findOneArgs.$and).toEqual([
237+
{ _id: '507f1f77bcf86cd799439011' },
238+
{ ownerId: 'user_123' },
239+
{} // empty object from softDeleteFilter
240+
]);
241+
});
197242
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use strict';
2+
3+
const mockFindOneAndUpdate = jest.fn();
4+
const mockFindOne = jest.fn();
5+
const mockLean = jest.fn();
6+
7+
const mockModel = {
8+
findOneAndUpdate: mockFindOneAndUpdate,
9+
findOne: mockFindOne,
10+
};
11+
12+
jest.mock('@urbackend/common', () => ({
13+
sanitize: (v) => v,
14+
Project: {},
15+
getConnection: jest.fn().mockResolvedValue({}),
16+
getCompiledModel: jest.fn(() => mockModel),
17+
dispatchWebhooks: jest.fn(),
18+
QueryEngine: jest.fn(),
19+
validateData: jest.fn(),
20+
validateUpdateData: jest.fn(),
21+
isValidId: () => true
22+
}));
23+
24+
const { deleteSingleDoc, getSingleDoc } = require('../controllers/data.controller');
25+
26+
function makeReq(overrides = {}) {
27+
return {
28+
params: { collectionName: 'posts', id: '507f1f77bcf86cd799439011' },
29+
project: {
30+
_id: 'proj_1',
31+
resources: { db: { isExternal: false } },
32+
collections: [{ name: 'posts', model: [] }],
33+
},
34+
query: {},
35+
...overrides,
36+
};
37+
}
38+
39+
function makeRes() {
40+
const res = {
41+
statusCode: null,
42+
body: null,
43+
status: jest.fn().mockReturnThis(),
44+
json: jest.fn().mockReturnThis(),
45+
};
46+
return res;
47+
}
48+
49+
describe('Soft Delete in data.controller', () => {
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
});
53+
54+
test('deleteSingleDoc sets isDeleted: true instead of hard deleting', async () => {
55+
const req = makeReq();
56+
const res = makeRes();
57+
const before = Date.now();
58+
59+
const doc = { _id: '507f1f77bcf86cd799439011', isDeleted: false };
60+
mockFindOneAndUpdate.mockReturnValue({
61+
lean: jest.fn().mockResolvedValue(doc)
62+
});
63+
64+
await deleteSingleDoc(req, res);
65+
66+
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
67+
expect.objectContaining({ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } }),
68+
expect.objectContaining({
69+
$set: expect.objectContaining({ isDeleted: true, deletedAt: expect.any(Date) })
70+
}),
71+
{ new: false }
72+
);
73+
74+
const deletedAt = mockFindOneAndUpdate.mock.calls[0][1].$set.deletedAt;
75+
expect(deletedAt.getTime()).toBeGreaterThanOrEqual(before - 1000);
76+
expect(deletedAt.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
77+
78+
expect(res.json).toHaveBeenCalledWith({
79+
success: true,
80+
data: { id: '507f1f77bcf86cd799439011' },
81+
message: "Document moved to trash"
82+
});
83+
});
84+
85+
test('deleteSingleDoc returns 404 if document is already soft-deleted or not found', async () => {
86+
const req = makeReq();
87+
const res = makeRes();
88+
89+
mockFindOneAndUpdate.mockReturnValue({
90+
lean: jest.fn().mockResolvedValue(null)
91+
});
92+
93+
await deleteSingleDoc(req, res);
94+
95+
expect(res.status).toHaveBeenCalledWith(404);
96+
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' });
97+
});
98+
});

0 commit comments

Comments
 (0)