fix(collections): moving a place out of a list needs the same role as deleting it

Moving a saved place to another list takes it out of the source list, but
updatePlace only asked for edit rights there, so an editor could empty a
shared list they may not delete from. The source now takes owner or admin,
like a delete, and the target still takes edit rights. REST and the
update_collection_place tool share the service path. The Move to list
action on desktop and phone follows the delete permission.
This commit is contained in:
Maurice
2026-10-12 09:17:02 +02:00
parent 6c8321cd63
commit aa1bb41cf5
11 changed files with 172 additions and 7 deletions
@@ -304,7 +304,7 @@ export class CollectionsMcp {
@Tool({
name: 'update_collection_place',
description:
"Update a saved place's name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).",
"Update a saved place's name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id). Moving a place out of a list needs the same role there as deleting it (owner or admin).",
inputSchema: { placeId: idSchema, ...collectionPlaceUpdateRequestSchema.shape, links: linksInput },
annotations: TOOL_ANNOTATIONS_WRITE,
when: collectionsAddonOn,
@@ -198,9 +198,19 @@ export class CollectionsService {
/** Delete a place — owner or admin only. */
async assertCanDelete(userId: number, collectionId: number): Promise<void> {
await this.assertOwnerOrAdmin(userId, collectionId, 'Only an admin can delete places from this list');
}
/** Move a place out of a list to another one. It leaves the list just as a
* delete would, so it takes the same role. */
private async assertCanMoveOut(userId: number, collectionId: number): Promise<void> {
await this.assertOwnerOrAdmin(userId, collectionId, 'Only an admin can move places out of this list');
}
private async assertOwnerOrAdmin(userId: number, collectionId: number, refusal: string): Promise<void> {
const r = await this.roleOf(userId, collectionId);
if (r === null) httpError(404, 'Collection not found');
if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list');
if (r !== 'owner' && r !== 'admin') httpError(403, refusal);
}
private async ownerOf(collectionId: number): Promise<number> {
@@ -919,6 +929,7 @@ export class CollectionsService {
let movedTo: number | null = null;
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
await this.assertCanMoveOut(userId, currentCollection);
await this.assertCanEdit(userId, body.collection_id);
write.collection_id = body.collection_id;
write.owner_id = await this.ownerOf(body.collection_id);
+59
View File
@@ -711,6 +711,65 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
).toBe(400);
});
// ── move out of a shared list ────────────────────────────────────────────
// Moving a place out removes it from the source list, so it takes the same
// role there as deleting it: an editor is refused, an admin is not.
it('COLLECTIONS-E2E-051: moving a place out of a shared list needs admin, not editor', async () => {
const shared = (
await request(server)
.post('/api/addons/collections')
.set('Cookie', sessionCookie(ownerId))
.send({ name: 'Shared moves' })
).body;
const editorId = createUser(db as never).user.id;
const adminId = createUser(db as never).user.id;
const join = async (userId: number, role: 'editor' | 'admin') => {
await request(server)
.post('/api/addons/collections/invite')
.set('Cookie', sessionCookie(ownerId))
.send({ collection_id: shared.id, user_id: userId, role });
await request(server)
.post('/api/addons/collections/invite/accept')
.set('Cookie', sessionCookie(userId))
.send({ collection_id: shared.id });
};
await join(editorId, 'editor');
await join(adminId, 'admin');
const place = (
await request(server)
.post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId))
.send({ collection_id: shared.id, name: 'Pergamon' })
).body.place;
const editorList = (
await request(server)
.post('/api/addons/collections')
.set('Cookie', sessionCookie(editorId))
.send({ name: 'Editor own' })
).body;
const adminList = (
await request(server)
.post('/api/addons/collections')
.set('Cookie', sessionCookie(adminId))
.send({ name: 'Admin own' })
).body;
const refused = await request(server)
.patch(`/api/addons/collections/places/${place.id}`)
.set('Cookie', sessionCookie(editorId))
.send({ collection_id: editorList.id });
expect(refused.status).toBe(403);
expect(refused.body.error).toBe('Only an admin can move places out of this list');
expect((await findRow(orm, CollectionPlaces, { id: place.id }))?.collection_id).toBe(shared.id);
const moved = await request(server)
.patch(`/api/addons/collections/places/${place.id}`)
.set('Cookie', sessionCookie(adminId))
.send({ collection_id: adminList.id });
expect(moved.status).toBe(200);
expect((await findRow(orm, CollectionPlaces, { id: place.id }))?.collection_id).toBe(adminList.id);
});
// ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
const col = (
@@ -477,6 +477,24 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col
expect(cleared.place.rating_count).toBe(0);
});
});
it('update_collection_place refuses an editor moving a place out of a shared list', async () => {
const { user: owner } = createUser(testDb);
const { user: editor } = createUser(testDb);
const shared = await seedCollection(owner.id, 'Shared');
await addMember(shared, editor.id, 'editor');
const own = await seedCollection(editor.id, 'Mine');
const pid = await seedPlace(shared, owner.id, 'Gate');
await withHarness(editor.id, async (h) => {
const res = await h.client.callTool({
name: 'update_collection_place',
arguments: { placeId: pid, collection_id: own },
});
expect(res.isError).toBe(true);
expect(errorText(res)).toBe('Only an admin can move places out of this list');
});
expect((await findRow(orm, CollectionPlaces, { id: pid }))?.collection_id).toBe(shared);
});
});
// ---------------------------------------------------------------------------
@@ -461,6 +461,64 @@ describe('status + updatePlace move', () => {
await expect(svc.updatePlace(owner.id, p.id, { collection_id: b.id })).rejects.toThrow();
});
// Moving a place out takes it away from the source list, so the source needs
// the same role as deleting it there (owner or admin), not just edit rights.
it('COLLECTIONS-SVC-018b: an editor of the source list cannot move a place out of it', async () => {
const owner = createUser(testDb).user;
const shared = await svc.createCollection(owner.id, { name: 'Shared' });
const editor = createUser(testDb).user;
await joinCollection(shared.id, editor.id, 'editor');
const own = await svc.createCollection(editor.id, { name: 'Mine' });
const l = await svc.createLabel(owner.id, shared.id, 'Berlin');
const { place: p } = await svc.savePlace(owner.id, { collection_id: shared.id, name: 'Kept' });
await svc.updatePlace(owner.id, p.id, { label_ids: [l.id] });
await expect(svc.updatePlace(editor.id, p.id, { collection_id: own.id })).rejects.toThrow(
'Only an admin can move places out of this list',
);
const row = await findRow(await orm(), CollectionPlaces, { id: p.id });
expect(row?.collection_id).toBe(shared.id);
expect(row?.owner_id).toBe(owner.id);
expect((await svc.getCollection(owner.id, shared.id)).places.find(x => x.id === p.id)?.label_ids).toEqual([l.id]);
});
it('COLLECTIONS-SVC-018c: an admin of the source list may move a place out of it', async () => {
const owner = createUser(testDb).user;
const shared = await svc.createCollection(owner.id, { name: 'Shared' });
const admin = createUser(testDb).user;
await joinCollection(shared.id, admin.id, 'admin');
const own = await svc.createCollection(admin.id, { name: 'Mine' });
const { place: p } = await svc.savePlace(owner.id, { collection_id: shared.id, name: 'Movable' });
const moved = await svc.updatePlace(admin.id, p.id, { collection_id: own.id });
expect(moved.collection_id).toBe(own.id);
});
it('COLLECTIONS-SVC-018d: an editor may still edit a place when collection_id names the list it is in', async () => {
const owner = createUser(testDb).user;
const shared = await svc.createCollection(owner.id, { name: 'Shared' });
const editor = createUser(testDb).user;
await joinCollection(shared.id, editor.id, 'editor');
const { place: p } = await svc.savePlace(owner.id, { collection_id: shared.id, name: 'Old' });
const res = await svc.updatePlace(editor.id, p.id, { collection_id: shared.id, name: 'New' });
expect(res.name).toBe('New');
expect(res.collection_id).toBe(shared.id);
});
it('COLLECTIONS-SVC-018e: a viewer of the source list still gets the read-only refusal on a move', async () => {
const owner = createUser(testDb).user;
const shared = await svc.createCollection(owner.id, { name: 'Shared' });
const viewer = createUser(testDb).user;
await joinCollection(shared.id, viewer.id, 'viewer');
const own = await svc.createCollection(viewer.id, { name: 'Mine' });
const { place: p } = await svc.savePlace(owner.id, { collection_id: shared.id, name: 'X' });
await expect(svc.updatePlace(viewer.id, p.id, { collection_id: own.id })).rejects.toThrow(
'You have read-only access to this list',
);
});
// #1870: the address column was missing from the UPDATE set, so a typo or a
// moved restaurant could only be fixed by deleting and re-adding the place.
it('COLLECTIONS-SVC-019: updatePlace corrects the address and clears it with null', async () => {