Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8596daf

Browse files
tiagoevanpabhinavkrindougfabris
authored
fix: Orphan team when last owner user deleted (#36807)
Co-authored-by: Abhinav Kumar <[email protected]> Co-authored-by: Douglas Fabris <[email protected]>
1 parent 7f1b834 commit 8596daf

File tree

11 files changed

+598
-43
lines changed

11 files changed

+598
-43
lines changed

.changeset/brave-socks-battle.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/core-services': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
Adds a `deletedRooms` field to the `users.delete` endpoint response, indicating which rooms were deleted as part of the user deletion process.

.changeset/stale-sloths-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
---
4+
5+
Fix issue where a team would become orphaned when its last owner was deleted.
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { expect } from 'chai';
2+
import proxyquireRaw from 'proxyquire';
3+
import * as sinon from 'sinon';
4+
5+
const proxyquire = proxyquireRaw.noCallThru();
6+
7+
type Stubbed = { [k: string]: any };
8+
9+
describe('eraseTeam (TypeScript) module', () => {
10+
let sandbox: sinon.SinonSandbox;
11+
let stubs: Stubbed;
12+
let subject: any;
13+
14+
beforeEach(() => {
15+
sandbox = sinon.createSandbox();
16+
17+
stubs = {
18+
'Team': {
19+
getMatchingTeamRooms: sandbox.stub().resolves([]),
20+
unsetTeamIdOfRooms: sandbox.stub().resolves(),
21+
removeAllMembersFromTeam: sandbox.stub().resolves(),
22+
deleteById: sandbox.stub().resolves(),
23+
},
24+
'Users': {
25+
findOneById: sandbox.stub().resolves(null),
26+
},
27+
'Rooms': {
28+
findOneById: sandbox.stub().resolves(null),
29+
},
30+
'eraseRoomStub': sandbox.stub().resolves(true),
31+
'deleteRoomStub': sandbox.stub().resolves(),
32+
'../../../../server/lib/logger/system': {
33+
SystemLogger: {
34+
error: sandbox.stub(),
35+
},
36+
},
37+
'@rocket.chat/apps': {
38+
AppEvents: {
39+
IPreRoomDeletePrevent: 'IPreRoomDeletePrevent',
40+
IPostRoomDeleted: 'IPostRoomDeleted',
41+
},
42+
Apps: {
43+
self: { isLoaded: () => false },
44+
getBridges: () => ({
45+
getListenerBridge: () => ({
46+
roomEvent: sandbox.stub().resolves(false),
47+
}),
48+
}),
49+
},
50+
},
51+
'@rocket.chat/models': {
52+
Rooms: {
53+
findOneById: (...args: any[]) => stubs.Rooms.findOneById(...args),
54+
},
55+
Users: {
56+
findOneById: (...args: any[]) => stubs.Users.findOneById(...args),
57+
},
58+
},
59+
'@rocket.chat/core-services': {
60+
MeteorError: (function () {
61+
class MeteorError extends Error {
62+
public error: string | undefined;
63+
64+
public details: any;
65+
66+
constructor(message?: string, error?: string, details?: any) {
67+
super(message);
68+
this.error = error;
69+
this.details = details;
70+
}
71+
}
72+
return MeteorError;
73+
})(),
74+
},
75+
};
76+
77+
subject = proxyquire('./eraseTeam', {
78+
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
79+
'@rocket.chat/models': stubs['@rocket.chat/models'],
80+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
81+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
82+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
83+
'@rocket.chat/core-services': {
84+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
85+
Team: stubs.Team,
86+
},
87+
});
88+
});
89+
90+
afterEach(() => {
91+
sandbox.restore();
92+
});
93+
94+
describe('eraseTeamShared', () => {
95+
it('throws when user is undefined', async () => {
96+
// eslint-disable-next-line @typescript-eslint/no-empty-function
97+
await expect(subject.eraseTeamShared(undefined, { _id: 'team1', roomId: 'teamRoom' }, [], () => {})).to.be.rejected;
98+
});
99+
100+
it('erases provided rooms (excluding team.roomId) and cleans up team', async () => {
101+
const team = { _id: 'team-id', roomId: 'team-room' };
102+
const user = { _id: 'user-1', username: 'u' };
103+
stubs.Team.getMatchingTeamRooms.resolves(['room-1', 'room-2', team.roomId]);
104+
105+
const erased: Array<{ rid: string; user: any }> = [];
106+
const eraseRoomFn = async (rid: string, user: any) => {
107+
erased.push({ rid, user });
108+
};
109+
110+
await subject.eraseTeamShared(user, team, ['room-1', 'room-2', team.roomId], eraseRoomFn);
111+
112+
expect(erased.some((r) => r.rid === 'room-1')).to.be.true;
113+
expect(erased.some((r) => r.rid === 'room-2')).to.be.true;
114+
sinon.assert.calledOnce(stubs.Team.unsetTeamIdOfRooms);
115+
expect(erased.some((r) => r.rid === team.roomId)).to.be.true;
116+
sinon.assert.calledOnce(stubs.Team.removeAllMembersFromTeam);
117+
sinon.assert.calledOnce(stubs.Team.deleteById);
118+
});
119+
});
120+
121+
describe('eraseTeam', () => {
122+
it('calls eraseRoom for the team main room (via eraseTeamShared)', async () => {
123+
const team = { _id: 't1', roomId: 't-room' };
124+
const user = { _id: 'u1', username: 'u', name: 'User' };
125+
stubs.Team.getMatchingTeamRooms.resolves([]);
126+
const { eraseRoomStub } = stubs;
127+
eraseRoomStub.resolves(true);
128+
129+
await subject.eraseTeam(user, team, []);
130+
131+
sinon.assert.calledWith(eraseRoomStub, team.roomId, 'u1');
132+
});
133+
});
134+
135+
describe('eraseTeamOnRelinquishRoomOwnerships', () => {
136+
it('returns successfully deleted room ids only', async () => {
137+
const team = { _id: 't1', roomId: 't-room' };
138+
stubs.Team.getMatchingTeamRooms.resolves(['r1', 'r2']);
139+
140+
stubs.Rooms.findOneById.withArgs('r1').resolves({ _id: 'r1', federated: false });
141+
stubs.Rooms.findOneById.withArgs('r2').resolves(null);
142+
143+
stubs.deleteRoomStub.withArgs('r1').resolves();
144+
stubs.deleteRoomStub.withArgs('r2').rejects(new Error('boom'));
145+
146+
const base = proxyquire('./eraseTeam', {
147+
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
148+
'@rocket.chat/models': stubs['@rocket.chat/models'],
149+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
150+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
151+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
152+
'@rocket.chat/core-services': {
153+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
154+
Team: stubs.Team,
155+
},
156+
});
157+
158+
const result: string[] = await base.eraseTeamOnRelinquishRoomOwnerships(team, ['r1', 'r2']);
159+
expect(result).to.be.an('array').that.includes('r1').and.not.includes('r2');
160+
});
161+
});
162+
163+
describe('eraseRoomLooseValidation', () => {
164+
let baseModule: any;
165+
166+
beforeEach(() => {
167+
baseModule = proxyquire('./eraseTeam', {
168+
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
169+
'@rocket.chat/models': stubs['@rocket.chat/models'],
170+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
171+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
172+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
173+
'@rocket.chat/core-services': {
174+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
175+
Team: stubs.Team,
176+
},
177+
});
178+
});
179+
180+
it('returns false when room not found', async () => {
181+
stubs.Rooms.findOneById.resolves(null);
182+
const res = await baseModule.eraseRoomLooseValidation('does-not-exist');
183+
expect(res).to.be.false;
184+
});
185+
186+
it('returns false when room.federated is true', async () => {
187+
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: true });
188+
const res = await baseModule.eraseRoomLooseValidation('r');
189+
expect(res).to.be.false;
190+
});
191+
192+
it('returns false when app pre-delete prevents deletion', async () => {
193+
const listenerStub = sandbox.stub().resolves(true);
194+
const AppsStub = {
195+
AppEvents: stubs['@rocket.chat/apps'].AppEvents,
196+
Apps: {
197+
self: { isLoaded: () => true },
198+
getBridges: () => ({ getListenerBridge: () => ({ roomEvent: listenerStub }) }),
199+
},
200+
};
201+
202+
const m = proxyquire('./eraseTeam', {
203+
'@rocket.chat/apps': AppsStub,
204+
'@rocket.chat/models': stubs['@rocket.chat/models'],
205+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
206+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
207+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
208+
'@rocket.chat/core-services': {
209+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
210+
Team: stubs.Team,
211+
},
212+
});
213+
214+
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
215+
216+
const res = await m.eraseRoomLooseValidation('r');
217+
expect(listenerStub.calledOnce).to.be.true;
218+
expect(res).to.be.false;
219+
});
220+
221+
it('logs and returns false when deleteRoom throws', async () => {
222+
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
223+
stubs.deleteRoomStub.rejects(new Error('boom'));
224+
225+
const m = proxyquire('./eraseTeam', {
226+
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
227+
'@rocket.chat/models': stubs['@rocket.chat/models'],
228+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
229+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
230+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
231+
'@rocket.chat/core-services': {
232+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
233+
Team: stubs.Team,
234+
},
235+
});
236+
237+
const res = await m.eraseRoomLooseValidation('r');
238+
expect(res).to.be.false;
239+
sinon.assert.calledOnce(stubs['../../../../server/lib/logger/system'].SystemLogger.error);
240+
});
241+
242+
it('calls post-deleted event and returns true on success', async () => {
243+
const roomEventStub = sandbox.stub().onFirstCall().resolves(false).onSecondCall().resolves();
244+
const AppsStub = {
245+
AppEvents: stubs['@rocket.chat/apps'].AppEvents,
246+
Apps: {
247+
self: { isLoaded: () => true },
248+
getBridges: () => ({ getListenerBridge: () => ({ roomEvent: roomEventStub }) }),
249+
},
250+
};
251+
252+
stubs.deleteRoomStub.resolves();
253+
const m = proxyquire('./eraseTeam', {
254+
'@rocket.chat/apps': AppsStub,
255+
'@rocket.chat/models': stubs['@rocket.chat/models'],
256+
'../../../../server/lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
257+
'../../../lib/server/functions/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
258+
'../../../../server/lib/logger/system': stubs['../../../../server/lib/logger/system'],
259+
'@rocket.chat/core-services': {
260+
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
261+
Team: stubs.Team,
262+
},
263+
});
264+
265+
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
266+
267+
const res = await m.eraseRoomLooseValidation('r');
268+
expect(res).to.be.true;
269+
sinon.assert.calledTwice(roomEventStub);
270+
});
271+
});
272+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { AppEvents, Apps } from '@rocket.chat/apps';
2+
import { MeteorError, Team } from '@rocket.chat/core-services';
3+
import type { AtLeast, IRoom, ITeam, IUser } from '@rocket.chat/core-typings';
4+
import { Rooms } from '@rocket.chat/models';
5+
6+
import { eraseRoom } from '../../../../server/lib/eraseRoom';
7+
import { SystemLogger } from '../../../../server/lib/logger/system';
8+
import { deleteRoom } from '../../../lib/server/functions/deleteRoom';
9+
10+
type eraseRoomFnType = (rid: string, user: AtLeast<IUser, '_id' | 'username' | 'name'>) => Promise<boolean | void>;
11+
12+
export const eraseTeamShared = async (
13+
user: AtLeast<IUser, '_id' | 'username' | 'name'>,
14+
team: ITeam,
15+
roomsToRemove: IRoom['_id'][] = [],
16+
eraseRoomFn: eraseRoomFnType,
17+
) => {
18+
const rooms: string[] = roomsToRemove.length
19+
? (await Team.getMatchingTeamRooms(team._id, roomsToRemove)).filter((roomId) => roomId !== team.roomId)
20+
: [];
21+
22+
if (!user) {
23+
throw new MeteorError('Invalid user provided for erasing team', 'error-invalid-user', {
24+
method: 'eraseTeamShared',
25+
});
26+
}
27+
28+
// If we got a list of rooms to delete along with the team, remove them first
29+
await Promise.all(rooms.map((room) => eraseRoomFn(room, user)));
30+
31+
// Move every other room back to the workspace
32+
await Team.unsetTeamIdOfRooms(user, team);
33+
34+
// Remove the team's main room
35+
await eraseRoomFn(team.roomId, user);
36+
37+
// Delete all team memberships
38+
await Team.removeAllMembersFromTeam(team._id);
39+
40+
// And finally delete the team itself
41+
await Team.deleteById(team._id);
42+
};
43+
44+
export const eraseTeam = async (user: AtLeast<IUser, '_id' | 'username' | 'name'>, team: ITeam, roomsToRemove: IRoom['_id'][]) => {
45+
await eraseTeamShared(user, team, roomsToRemove, async (rid, user) => {
46+
return eraseRoom(rid, user._id);
47+
});
48+
};
49+
50+
/**
51+
* @param team
52+
* @param roomsToRemove
53+
* @returns deleted room ids
54+
*/
55+
export const eraseTeamOnRelinquishRoomOwnerships = async (team: ITeam, roomsToRemove: IRoom['_id'][] = []): Promise<string[]> => {
56+
const deletedRooms = new Set<string>();
57+
await eraseTeamShared({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket.Cat' }, team, roomsToRemove, async (rid) => {
58+
const isDeleted = await eraseRoomLooseValidation(rid);
59+
if (isDeleted) {
60+
deletedRooms.add(rid);
61+
}
62+
});
63+
return Array.from(deletedRooms);
64+
};
65+
66+
export async function eraseRoomLooseValidation(rid: string): Promise<boolean> {
67+
const room = await Rooms.findOneById(rid);
68+
69+
if (!room) {
70+
return false;
71+
}
72+
73+
if (room.federated) {
74+
return false;
75+
}
76+
77+
if (Apps.self?.isLoaded()) {
78+
const prevent = await Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPreRoomDeletePrevent, room);
79+
if (prevent) {
80+
return false;
81+
}
82+
}
83+
84+
try {
85+
await deleteRoom(rid);
86+
} catch (e) {
87+
SystemLogger.error(e);
88+
return false;
89+
}
90+
91+
if (Apps.self?.isLoaded()) {
92+
void Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPostRoomDeleted, room);
93+
}
94+
95+
return true;
96+
}

0 commit comments

Comments
 (0)