mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user