Files
TREK/client/tests/unit/remoteEventHandler/packingPrivacy.test.ts
Maurice 8f171453d3 fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket
in the trip room. Ticking something off your own list through an assistant
pushed that row to every other member.

The REST and RPC surfaces have scoped these events since #858 — both go
through emitToViewers, which delivers to the owner and the recipients and
nobody else. The MCP tools called safeBroadcast, which took three arguments
and had no way to say who may see the thing that changed.

So safeBroadcast learned that fourth argument, and the packing tools pass
viewersOf(item), which is the same answer the REST route asks for. A shared
item still answers null and still goes to the room in a three-argument call,
so nothing about it changes — including what the hundred-odd broadcast mocks
in the suite record.

The client stops trusting the wire on this too. It used to `put` whatever
arrived, and the offline read hands back every cached row for the trip with
no privacy filter, so a single stray event lodged another member's item in
that browser permanently. It now drops a restricted item that is neither
mine nor shared with me — deleting rather than skipping, so a list that was
already leaked into heals itself the next time an event about that item
comes past.

Closes #1976
2026-08-20 17:30:01 +02:00

91 lines
3.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* What the offline cache is allowed to keep of another member's packing list
* (#1976).
*
* The server scopes these events now, so in ordinary running nothing here has
* anything to refuse. It refuses anyway, because a leak on the wire used to
* become permanent on this side: the write is a `put`, the offline read hands
* back every cached row for the trip without a privacy filter, and nothing ever
* prunes. One stray event put another member's item into this browser for good,
* and it surfaced every time a read fell back to the cache — offline, captive
* portal, dropped connection.
*
* So the interesting case is not that the item is skipped. It is that a row
* which arrived before any of this existed gets deleted when the next event
* about it comes past, which is what makes an already-leaked list heal itself.
*/
const { packingItems } = vi.hoisted(() => ({
packingItems: { put: vi.fn(async () => undefined), delete: vi.fn(async () => undefined) },
}));
vi.mock('../../../src/db/offlineDb', () => ({
offlineDb: {
packingItems,
// The handler module touches these at import time only.
trips: {}, days: {}, places: {}, assignments: {},
},
}));
import { useTripStore } from '../../../src/store/tripStore';
import { useAuthStore } from '../../../src/store/authStore';
import { resetAllStores } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories';
const ME = 7;
const SOMEONE_ELSE = 99;
beforeEach(() => {
resetAllStores();
packingItems.put.mockClear();
packingItems.delete.mockClear();
useAuthStore.setState({ user: { id: ME, username: 'me', email: 'me@example.test' } as never });
});
const send = (item: unknown) =>
useTripStore.getState().handleRemoteEvent({ type: 'packing:updated', item } as never);
describe('a packing item arriving over the wire', () => {
it('is cached when it is shared with the whole trip', async () => {
send(buildPackingItem({ id: 1, is_private: 0 }));
expect(packingItems.put).toHaveBeenCalledTimes(1);
expect(packingItems.delete).not.toHaveBeenCalled();
});
it('is cached when it is my own private one', async () => {
send(buildPackingItem({ id: 2, is_private: 1, owner_id: ME }));
expect(packingItems.put).toHaveBeenCalledTimes(1);
});
it('is cached when it is private but shared with me', async () => {
send(buildPackingItem({
id: 3, is_private: 1, owner_id: SOMEONE_ELSE,
recipients: [{ user_id: ME, username: 'me' }],
}));
expect(packingItems.put).toHaveBeenCalledTimes(1);
});
/* The assertion that would have caught the leak. */
it('is not cached when it is somebody else s private one', async () => {
send(buildPackingItem({ id: 4, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.put).not.toHaveBeenCalled();
});
it('removes a copy that an earlier leak already left behind', async () => {
send(buildPackingItem({ id: 5, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.delete).toHaveBeenCalledWith(5);
});
/*
* A signed-out or not-yet-loaded session has no id to compare against.
* Refusing is the safe answer: there is no user whose list this could be.
*/
it('keeps a restricted item out when nobody is signed in', async () => {
useAuthStore.setState({ user: null });
send(buildPackingItem({ id: 6, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.put).not.toHaveBeenCalled();
});
});