mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
8f171453d3
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
91 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|