mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c7c9ba24a3
The photo picker's Select all took only the pages already loaded, 50 at a time, and replaced whatever had been picked on another tab. It now loads the rest of a day, trip or date-range search before selecting (cancelled by closing the picker or switching tab), adds to earlier picks, and Deselect all only clears what is in view. Add stays disabled until that loading is done. The whole-library tab keeps selecting what is loaded and shows the count as "N+". A date-filtered Immich search stopped at 1,000 photos without a word, because its page budget was absolute; it is now relative to the page asked for, with an absolute ceiling of 270 raw pages sized so the picker's deepest page still gets a full answer. Adds go out in batches of 500 ids, under the JSON body limit, and each batch is sent oldest first, so photos land in an entry in the order they were taken. When a later batch fails, the photos already saved are counted and the journey reloads while the error still shows, through one hook for desktop and phone. After a provider add, the capture-time lookup runs at most four at a time per user, shared by all of that user's batches. When it fills a row, a journey:photos:updated event reloads open journeys once per batch, from the REST routes and the MCP add tool alike. Device uploads, which arrive one file per request, get the same lookup without the event. Addresses discussion #1587.
159 lines
8.3 KiB
TypeScript
159 lines
8.3 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;
|
|
// 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);
|
|
});
|
|
});
|