Skip to content

Commit 63ce44f

Browse files
pyphiliakim
andauthored
fix: check admin permission on recycled items for deletion (#1949)
* fix: check admin permission on recycled items for deletion * refactor: fix PR requested changes --------- Co-authored-by: kim <kim.phanhoang@epfl.ch>
1 parent 001009c commit 63ce44f

4 files changed

Lines changed: 201 additions & 10 deletions

File tree

src/services/item/item.controller.update.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,87 @@ describe('Item routes tests', () => {
11681168
// ws should not fail
11691169
}, MULTIPLE_ITEMS_LOADING_TIME);
11701170
});
1171+
it('Delete recycled items', async () => {
1172+
const {
1173+
actor,
1174+
items: [item1, item2],
1175+
itemMemberships: [im1, im2],
1176+
} = await seedFromJson({
1177+
items: [
1178+
{
1179+
isDeleted: true,
1180+
memberships: [{ account: 'actor', permission: PermissionLevel.Admin }],
1181+
},
1182+
{
1183+
isDeleted: true,
1184+
memberships: [{ account: 'actor', permission: PermissionLevel.Admin }],
1185+
},
1186+
],
1187+
});
1188+
assertIsDefined(actor);
1189+
assertIsMemberForTest(actor);
1190+
mockAuthenticate(actor);
1191+
1192+
const items = [item1, item2];
1193+
const response = await app.inject({
1194+
method: HttpMethod.Delete,
1195+
url: '/items',
1196+
query: { id: items.map(({ id }) => id) },
1197+
});
1198+
expect(response.json()).toEqual(items.map(({ id }) => id));
1199+
expect(response.statusCode).toBe(StatusCodes.ACCEPTED);
1200+
await waitForExpect(async () => {
1201+
const remaining = await db.query.itemsRawTable.findMany({
1202+
where: inArray(itemsRawTable.id, [item1.id, item2.id]),
1203+
});
1204+
expect(remaining).toHaveLength(0);
1205+
const memberships = await db.query.itemMembershipsTable.findMany({
1206+
where: inArray(itemMembershipsTable.id, [im1.id, im2.id]),
1207+
});
1208+
expect(memberships).toHaveLength(0);
1209+
}, MULTIPLE_ITEMS_LOADING_TIME);
1210+
});
1211+
it('Do not delete items with write permission', async () => {
1212+
const {
1213+
actor,
1214+
items: [item1, item2],
1215+
} = await seedFromJson({
1216+
items: [
1217+
{
1218+
isDeleted: true,
1219+
memberships: [{ account: 'actor', permission: PermissionLevel.Write }],
1220+
},
1221+
{
1222+
isDeleted: true,
1223+
memberships: [{ account: 'actor', permission: PermissionLevel.Admin }],
1224+
},
1225+
],
1226+
});
1227+
assertIsDefined(actor);
1228+
assertIsMemberForTest(actor);
1229+
mockAuthenticate(actor);
1230+
1231+
const items = [item1, item2];
1232+
const response = await app.inject({
1233+
method: HttpMethod.Delete,
1234+
url: '/items',
1235+
query: { id: items.map(({ id }) => id) },
1236+
});
1237+
expect(response.json()).toEqual(items.map(({ id }) => id));
1238+
expect(response.statusCode).toBe(StatusCodes.ACCEPTED);
1239+
1240+
await new Promise((done) => {
1241+
// wait one second for the operation to apply, because this is already the start state
1242+
setTimeout(async () => {
1243+
const remaining = await db.query.itemsRawTable.findMany({
1244+
where: inArray(itemsRawTable.id, [item1.id, item2.id]),
1245+
});
1246+
expect(remaining).toHaveLength(2);
1247+
1248+
done(true);
1249+
}, 1000);
1250+
});
1251+
});
11711252
it('Bad request if one id is invalid', async () => {
11721253
const { actor, items } = await seedFromJson({
11731254
items: [
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { PermissionLevel } from '@graasp/sdk';
2+
3+
import { seedFromJson } from '../../../../../test/mocks/seed';
4+
import { db } from '../../../../drizzle/db';
5+
import { assertIsDefined } from '../../../../utils/assertions';
6+
import { MemberCannotAdminItem } from '../../../../utils/errors';
7+
import { RecycledItemDataRepository } from './recycled.repository';
8+
9+
const recycledRepository = new RecycledItemDataRepository();
10+
11+
describe('RecycledItemDataRepository', () => {
12+
describe('assertAdminAccessForItemIds', () => {
13+
it('resolve for item with admin permission', async () => {
14+
const { items, actor } = await seedFromJson({
15+
items: [{ memberships: [{ permission: PermissionLevel.Admin, account: 'actor' }] }],
16+
});
17+
assertIsDefined(actor);
18+
19+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
20+
await expect(
21+
recycledRepository.assertAdminAccessForItemIds(
22+
db,
23+
actor.id,
24+
items.map((i) => i.id),
25+
),
26+
).resolves;
27+
});
28+
29+
it('resolve for item with parent with admin permission', async () => {
30+
const {
31+
items: [_parent, child],
32+
actor,
33+
} = await seedFromJson({
34+
items: [
35+
{
36+
memberships: [{ permission: PermissionLevel.Admin, account: 'actor' }],
37+
children: [{}],
38+
},
39+
],
40+
});
41+
assertIsDefined(actor);
42+
43+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
44+
await expect(recycledRepository.assertAdminAccessForItemIds(db, actor.id, [child.id]))
45+
.resolves;
46+
});
47+
48+
it('reject for item with write permission', async () => {
49+
const { items, actor } = await seedFromJson({
50+
items: [{ memberships: [{ permission: PermissionLevel.Write, account: 'actor' }] }],
51+
});
52+
assertIsDefined(actor);
53+
54+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
55+
await expect(
56+
recycledRepository.assertAdminAccessForItemIds(
57+
db,
58+
actor.id,
59+
items.map((i) => i.id),
60+
),
61+
).rejects.toBeInstanceOf(MemberCannotAdminItem);
62+
});
63+
64+
it('reject for one item with write permission', async () => {
65+
const { items, actor } = await seedFromJson({
66+
items: [
67+
{ memberships: [{ permission: PermissionLevel.Admin, account: 'actor' }] },
68+
{ memberships: [{ permission: PermissionLevel.Write, account: 'actor' }] },
69+
],
70+
});
71+
assertIsDefined(actor);
72+
73+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
74+
await expect(
75+
recycledRepository.assertAdminAccessForItemIds(
76+
db,
77+
actor.id,
78+
items.map((i) => i.id),
79+
),
80+
).rejects.toBeInstanceOf(MemberCannotAdminItem);
81+
});
82+
});
83+
});

src/services/item/plugins/recycled/recycled.repository.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import { and, asc, desc, isNotNull, ne } from 'drizzle-orm/sql';
55
import { type Paginated, type Pagination, PermissionLevel } from '@graasp/sdk';
66

77
import { type DBConnection } from '../../../../drizzle/db';
8-
import { isDescendantOrSelf } from '../../../../drizzle/operations';
8+
import { isAncestorOrSelf, isDescendantOrSelf } from '../../../../drizzle/operations';
99
import {
1010
itemMembershipsTable,
1111
itemsRawTable,
1212
membersView,
1313
recycledItemDatasTable,
1414
} from '../../../../drizzle/schema';
15-
import type { ItemRaw } from '../../../../drizzle/types';
15+
import type { ItemRaw, MemberRaw } from '../../../../drizzle/types';
1616
import { throwsIfParamIsInvalid } from '../../../../repositories/utils';
1717
import type { MinimalMember } from '../../../../types';
18+
import { MemberCannotAdminItem } from '../../../../utils/errors';
1819
import { ITEMS_PAGE_SIZE_MAX } from '../../constants';
1920
import type { FolderItem } from '../../discrimination';
2021

@@ -148,4 +149,37 @@ export class RecycledItemDataRepository {
148149

149150
return trees.map(({ descendants }) => descendants);
150151
}
152+
153+
/**
154+
* Returns whether the member has an admin access on all the items
155+
* @param dbConnection database connection
156+
* @param memberId id of member performing the task
157+
* @param ids item ids
158+
* @returns true if the member has access to all items
159+
*/
160+
async assertAdminAccessForItemIds(
161+
dbConnection: DBConnection,
162+
memberId: MemberRaw['id'],
163+
ids: ItemRaw['id'][],
164+
) {
165+
const im = await dbConnection
166+
.select({ itemId: itemsRawTable.id })
167+
.from(itemsRawTable)
168+
.innerJoin(
169+
itemMembershipsTable,
170+
and(
171+
eq(itemMembershipsTable.accountId, memberId),
172+
eq(itemMembershipsTable.permission, PermissionLevel.Admin),
173+
inArray(itemsRawTable.id, ids),
174+
isAncestorOrSelf(itemMembershipsTable.itemPath, itemsRawTable.path),
175+
),
176+
);
177+
178+
const imIds = im.map(({ itemId }) => itemId);
179+
const idsWithoutAccess = ids.filter((m) => !imIds.includes(m));
180+
if (idsWithoutAccess.length) {
181+
// return first id lacking access
182+
throw new MemberCannotAdminItem(idsWithoutAccess[0]);
183+
}
184+
}
151185
}

src/services/item/plugins/recycled/recycled.service.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,7 @@ export class RecycledBinService {
5353
ids: ItemRaw['id'][],
5454
) {
5555
// validate permission on parents
56-
// TODO: optimize!!!!!!
57-
for (const id of ids) {
58-
await this.authorizedItemService.assertAccessForItemId(dbConnection, {
59-
permission: PermissionLevel.Admin,
60-
accountId: member.id,
61-
itemId: id,
62-
});
63-
}
56+
await this.recycledItemRepository.assertAdminAccessForItemIds(dbConnection, member.id, ids);
6457

6558
const items = await this.recycledItemRepository.getDeletedTreesById(dbConnection, ids);
6659

0 commit comments

Comments
 (0)