mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
8864edda18
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows.
161 lines
8.4 KiB
TypeScript
161 lines
8.4 KiB
TypeScript
/**
|
|
* The addon gates of the migrated plugin surface, checked as one table.
|
|
*
|
|
* These gates used to live inside the deps factory's closure rather than in the
|
|
* router, which made them the single most likely thing to lose in the decorator
|
|
* migration: a handler that calls its service directly still works, just without the
|
|
* addon check, and neither the compiler nor the router tests would notice. The plan
|
|
* calls it out as the most probable security regression of the whole rollout.
|
|
*
|
|
* So every addon-gated method gets the same two assertions: with the addon off it is
|
|
* refused with that addon's message, and the underlying service is never touched.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
|
|
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
|
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
|
|
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
|
|
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
|
|
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
|
|
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
|
|
import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc';
|
|
import { CostsRpc } from '../../../src/nest/budget/costs.rpc';
|
|
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
|
|
import { makeDeps } from '../../helpers/rpc-host-deps';
|
|
|
|
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
|
|
|
|
/** Records every service call, so "the service was never touched" is checkable. */
|
|
function spyService(calls: string[], name: string) {
|
|
return new Proxy(
|
|
{},
|
|
{
|
|
get: (_t, prop) => (...args: unknown[]) => {
|
|
calls.push(`${name}.${String(prop)}`);
|
|
// Shapes the handlers destructure on the happy path.
|
|
if (prop === 'votePoll' || prop === 'createMessage') return { error: null, poll: {}, message: {} };
|
|
if (prop === 'listEntries') return [];
|
|
if (prop === 'getActivePlanId') return 1;
|
|
// A cost write asks whether its links may be made; null is "yes" (#2084).
|
|
if (prop === 'linkRefusal') return null;
|
|
// collections.deletePlace is async in production (it deletes a storage
|
|
// object); a promise-returning double here pins the fix that awaits it —
|
|
// an un-awaited call would leave an unhandled rejection unnoticed by every
|
|
// assertion below.
|
|
if (name === 'collections' && prop === 'deletePlace') return Promise.resolve(undefined);
|
|
if (String(prop).startsWith('delete')) return true;
|
|
if (String(prop).startsWith('list')) return [];
|
|
return { id: 1 };
|
|
},
|
|
},
|
|
) as never;
|
|
}
|
|
|
|
/** Everything granted, every trip accessible, every permission allowed, addons OFF. */
|
|
function build(addonOn: boolean) {
|
|
const calls: string[] = [];
|
|
const db = {
|
|
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
|
|
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
|
|
} as unknown as DatabaseService;
|
|
const guards = new PluginGuards(
|
|
db,
|
|
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
|
|
{ isAddonEnabled: vi.fn(() => addonOn) } as unknown as AddonsService,
|
|
);
|
|
const registry = createTestPluginRegistry([
|
|
new CollabRpc(spyService(calls, 'collab'), { broadcast: vi.fn() } as never, guards),
|
|
new AtlasRpc(spyService(calls, 'atlas'), guards),
|
|
new VacayRpc(spyService(calls, 'vacay'), guards),
|
|
// The photo method writes bytes, so its four extra deps need real shapes or the
|
|
// addon-on half of the table would fail for a reason that is not the gate.
|
|
new JournalRpc(spyService(calls, 'journey'), guards,
|
|
{ put: async () => undefined, delete: async () => undefined } as never,
|
|
{ get: () => '*' } as never,
|
|
{ scheduleUpload: () => undefined } as never,
|
|
{ prepare: () => ({ get: () => ({ email: 'u@example.test' }) }) } as never),
|
|
new CollectionsRpc(spyService(calls, 'collections'), guards),
|
|
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
|
|
]);
|
|
const granted = new Set([
|
|
'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal',
|
|
'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:write:vacay',
|
|
'db:read:collections', 'db:write:collections', 'db:read:costs', 'db:write:costs',
|
|
]);
|
|
return { calls, host: new PluginRpcHost('p', granted, makeDeps(), registry) };
|
|
}
|
|
|
|
/** Every addon-gated method with a payload that reaches its gate. */
|
|
const GATED: Array<[string, Record<string, unknown>, string]> = [
|
|
['collab.listNotes', { tripId: 1 }, 'collab'],
|
|
['collab.listPolls', { tripId: 1 }, 'collab'],
|
|
['collab.listMessages', { tripId: 1 }, 'collab'],
|
|
['collab.createNote', { tripId: 1, input: { title: 'n' } }, 'collab'],
|
|
['collab.createPoll', { tripId: 1, input: { question: 'q?', options: ['a', 'b'] } }, 'collab'],
|
|
['collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }, 'collab'],
|
|
['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab'],
|
|
['journal.listMine', {}, 'journey'],
|
|
['journal.getEntries', { journeyId: 7 }, 'journey'],
|
|
['journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }, 'journey'],
|
|
['journal.updateEntry', { entryId: 1, input: {} }, 'journey'],
|
|
['journal.deleteEntry', { entryId: 1 }, 'journey'],
|
|
['journal.createJourney', { input: { title: 'J' } }, 'journey'],
|
|
['journal.addEntryPhoto', { entryId: 1, input: { name: 'a.jpg', content_base64: 'eA==' } }, 'journey'],
|
|
['journal.deleteJourney', { journeyId: 7 }, 'journey'],
|
|
['atlas.visited', {}, 'atlas'],
|
|
['atlas.bucketList', {}, 'atlas'],
|
|
['atlas.markCountry', { code: 'JP' }, 'atlas'],
|
|
['atlas.unmarkCountry', { code: 'JP' }, 'atlas'],
|
|
['atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }, 'atlas'],
|
|
['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas'],
|
|
['atlas.createBucketItem', { input: { name: 'Kyoto' } }, 'atlas'],
|
|
['atlas.deleteBucketItem', { itemId: 1 }, 'atlas'],
|
|
['vacay.mine', {}, 'vacay'],
|
|
['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay'],
|
|
['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay'],
|
|
['collections.listMine', {}, 'collections'],
|
|
['collections.get', { id: 1 }, 'collections'],
|
|
['collections.create', { input: { name: 'C' } }, 'collections'],
|
|
['collections.update', { id: 1, input: { name: 'C' } }, 'collections'],
|
|
['collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }, 'collections'],
|
|
['collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }, 'collections'],
|
|
['collections.deletePlace', { placeId: 1 }, 'collections'],
|
|
['costs.getByTrip', { tripId: 1 }, 'costs'],
|
|
['costs.listMine', {}, 'costs'],
|
|
['costs.create', { tripId: 1, input: { name: 'H', price: 1 } }, 'costs'],
|
|
['costs.update', { tripId: 1, itemId: 1, input: { name: 'H' } }, 'costs'],
|
|
['costs.delete', { tripId: 1, itemId: 1 }, 'costs'],
|
|
];
|
|
|
|
describe('every addon-gated plugin method refuses when its addon is off', () => {
|
|
for (const [method, params, addon] of GATED) {
|
|
it(`ADDONGATE-${method} is refused with "the ${addon} addon is disabled"`, async () => {
|
|
const f = build(false);
|
|
const res = (await f.host.dispatch(req(method, params), 42)) as RpcError;
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(res.error.message).toBe(`the ${addon} addon is disabled`);
|
|
// The gate has to come before the service, not after it.
|
|
expect(f.calls).toEqual([]);
|
|
});
|
|
}
|
|
|
|
it('ADDONGATE-covers-every-gated-method', () => {
|
|
// Guards the table itself: if a later PR adds an addon-gated method without a row
|
|
// here, this number is the reminder.
|
|
expect(GATED).toHaveLength(38);
|
|
});
|
|
|
|
it('ADDONGATE-the-same-calls-succeed-with-the-addon-on', async () => {
|
|
const f = build(true);
|
|
for (const [method, params] of GATED) {
|
|
const res = await f.host.dispatch(req(method, params), 42);
|
|
expect(res.ok, `${method} should pass with the addon on`).toBe(true);
|
|
}
|
|
expect(f.calls.length).toBeGreaterThan(0);
|
|
});
|
|
});
|