Files
TREK/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts
T
2026-10-06 21:50:59 +02:00

495 lines
20 KiB
TypeScript

/**
* The refusal paths of the five addon-gated domains: no bound user, invalid input,
* and the service saying no.
*
* The happy paths and the addon gates live in addon-gates.rpc.test.ts and the domain
* suites; this file is about what happens when something is wrong, because those are
* the branches a move like this leaves untested by default.
*/
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc';
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { describe, it, expect, vi } from 'vitest';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({
k: 'req',
id: 'x',
method,
params,
});
const err = (r: unknown) => (r as RpcError).error;
/** A status-tagged error, the shape CollectionsService throws. */
function statusError(status: number, message: string) {
return Object.assign(new Error(message), { status });
}
function build(
overrides: {
collab?: Record<string, unknown>;
atlas?: Record<string, unknown>;
vacay?: Record<string, unknown>;
journey?: Record<string, unknown>;
collections?: Record<string, unknown>;
} = {},
) {
const db = {
findAccessible: vi.fn(async () => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as TripsRepository;
const guards = new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
{ getRole: vi.fn(async () => 'user') } as unknown as UsersRepository,
);
const realtime = { broadcast: vi.fn() } as never;
const registry = createTestPluginRegistry([
new CollabRpc(
{
createNote: vi.fn(() => ({})),
createPoll: vi.fn(() => ({})),
votePoll: vi.fn(() => ({ error: null, poll: {} })),
createMessage: vi.fn(() => ({ error: null, message: {} })),
listNotes: vi.fn(() => []),
listPolls: vi.fn(() => []),
listMessages: vi.fn(() => []),
...overrides.collab,
} as never,
realtime,
guards,
),
new AtlasRpc(
{
listVisitedCountries: vi.fn(() => []),
listManuallyVisitedRegions: vi.fn(() => []),
bucketList: vi.fn(() => []),
markCountry: vi.fn(),
unmarkCountry: vi.fn(),
markRegion: vi.fn(),
unmarkRegion: vi.fn(),
createBucketItem: vi.fn(() => ({})),
deleteBucketItem: vi.fn(() => true),
...overrides.atlas,
} as never,
guards,
),
new VacayRpc(
{
getPlanData: vi.fn(() => ({})),
getActivePlanId: vi.fn(() => 1),
toggleEntry: vi.fn(() => ({})),
toggleCompanyHoliday: vi.fn(() => ({})),
...overrides.vacay,
} as never,
guards,
),
new JournalRpc(
{
listJourneys: vi.fn(() => []),
listEntries: vi.fn(() => []),
createEntry: vi.fn(() => ({})),
updateEntry: vi.fn(() => ({})),
deleteEntry: vi.fn(() => true),
createJourney: vi.fn(() => ({})),
deleteJourney: vi.fn(() => true),
...overrides.journey,
} as never,
guards,
{} as never,
{} as never,
{} as never,
{} as never,
),
// deletePlace is async in production, so its double returns a promise: an
// un-awaited call in the RPC handler would let a rejection sail past
// mapCollectionError as an unhandled rejection while the handler still replied
// {deleted: true}.
new CollectionsRpc(
{
listCollections: vi.fn(() => []),
getCollection: vi.fn(() => ({})),
createCollection: vi.fn(() => ({})),
updateCollection: vi.fn(() => ({})),
savePlace: vi.fn(() => ({})),
copyToTrip: vi.fn(() => ({})),
deletePlace: vi.fn(() => Promise.resolve(undefined)),
...overrides.collections,
} as never,
guards,
),
]);
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',
]);
return new PluginRpcHost('p', granted, makeDeps(), registry);
}
/** Every user-scoped method, with the refusal it owes a job or onLoad. */
const USERLESS: Array<[string, Record<string, unknown>, string]> = [
['journal.listMine', {}, 'journal reads require an authenticated user context'],
['journal.getEntries', { journeyId: 7 }, 'journal reads require an authenticated user context'],
[
'journal.createEntry',
{ journeyId: 7, input: { entry_date: '2027-01-01' } },
'journal writes require an authenticated user context',
],
['journal.updateEntry', { entryId: 1, input: {} }, 'journal writes require an authenticated user context'],
['journal.deleteEntry', { entryId: 1 }, 'journal writes require an authenticated user context'],
['journal.createJourney', { input: { title: 'J' } }, 'journal writes require an authenticated user context'],
[
'journal.addEntryPhoto',
{ entryId: 1, input: { name: 'a.jpg', content_base64: 'eA==' } },
'journal writes require an authenticated user context',
],
['journal.deleteJourney', { journeyId: 7 }, 'journal writes require an authenticated user context'],
['atlas.visited', {}, 'atlas reads require an authenticated user context'],
['atlas.bucketList', {}, 'atlas reads require an authenticated user context'],
['atlas.markCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'],
['atlas.unmarkCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'],
[
'atlas.markRegion',
{ regionCode: 'JP-13', countryCode: 'JP' },
'atlas writes require an authenticated user context',
],
['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas writes require an authenticated user context'],
['atlas.createBucketItem', { input: { name: 'K' } }, 'atlas writes require an authenticated user context'],
['atlas.deleteBucketItem', { itemId: 1 }, 'atlas writes require an authenticated user context'],
['vacay.mine', {}, 'vacay reads require an authenticated user context'],
['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'],
['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'],
['collections.listMine', {}, 'collection reads require an authenticated user context'],
['collections.get', { id: 1 }, 'collection reads require an authenticated user context'],
['collections.create', { input: { name: 'C' } }, 'collection writes require an authenticated user context'],
['collections.update', { id: 1, input: { name: 'C' } }, 'collection writes require an authenticated user context'],
[
'collections.savePlace',
{ input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } },
'collection writes require an authenticated user context',
],
[
'collections.copyToTrip',
{ input: { trip_id: 1, place_ids: [1] } },
'collection writes require an authenticated user context',
],
['collections.deletePlace', { placeId: 1 }, 'collection writes require an authenticated user context'],
[
'collab.createNote',
{ tripId: 1, input: { title: 'n' } },
'collab note writes require an authenticated user context',
],
[
'collab.createPoll',
{ tripId: 1, input: { question: 'q', options: ['a', 'b'] } },
'collab poll writes require an authenticated user context',
],
[
'collab.votePoll',
{ tripId: 1, pollId: 1, optionIndex: 0 },
'collab poll writes require an authenticated user context',
],
['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab message writes require an authenticated user context'],
];
describe('addon-gated domains refuse a job or onLoad', () => {
for (const [method, params, message] of USERLESS) {
it(`ADDONERR-userless-${method}`, async () => {
const res = await build().dispatch(req(method, params), undefined);
expect(err(res).code).toBe('RESOURCE_FORBIDDEN');
expect(err(res).message).toBe(message);
});
}
});
describe('addon-gated domains validate their input', () => {
it('ADDONERR-001 collab refuses a blank note title, a thin poll and an oversized message', async () => {
const host = build();
expect(err(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: ' ' } }), 42)).message).toBe(
'note title is required',
);
expect(
err(
await host.dispatch(
req('collab.createPoll', { tripId: 1, input: { question: ' ', options: ['a', 'b'] } }),
42,
),
).message,
).toBe('poll question is required');
expect(
err(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'q', options: ['a'] } }), 42))
.message,
).toBe('a poll needs at least two options');
expect(
err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'x'.repeat(4001) }), 42)).message,
).toBe('message text is required (max 4000 chars)');
expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: ' ' }), 42)).message).toBe(
'message text is required (max 4000 chars)',
);
});
it('ADDONERR-002 the collab service reports its own failures as BAD_PARAMS', async () => {
const host = build({
collab: {
votePoll: vi.fn(() => ({ error: 'poll is closed' })),
createMessage: vi.fn(() => ({ error: 'reply target is gone' })),
},
});
expect(
err(await host.dispatch(req('collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }), 42)),
).toMatchObject({ code: 'BAD_PARAMS', message: 'poll is closed' });
expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42))).toMatchObject({
code: 'BAD_PARAMS',
message: 'reply target is gone',
});
});
it('ADDONERR-003 atlas rejects a code that is empty or too long', async () => {
const host = build();
expect(err(await host.dispatch(req('atlas.markCountry', { code: '' }), 42)).message).toBe(
'code must be a short code',
);
expect(err(await host.dispatch(req('atlas.markCountry', { code: 'TOOLONGCODE' }), 42)).message).toBe(
'code must be a short code',
);
expect(
err(await host.dispatch(req('atlas.markRegion', { regionCode: 'JP-13', countryCode: '' }), 42)).message,
).toBe('countryCode must be a short code');
expect(err(await host.dispatch(req('atlas.createBucketItem', { input: { name: ' ' } }), 42)).message).toBe(
'bucket item name is required',
);
});
it('ADDONERR-004 a missing bucket item is refused, naming it', async () => {
const host = build({ atlas: { deleteBucketItem: vi.fn(() => false) } });
expect(err(await host.dispatch(req('atlas.deleteBucketItem', { itemId: 404 }), 42)).message).toBe(
'no bucket item 404 for this user',
);
});
it("ADDONERR-004b a duplicate bucket item is the caller's mistake, not an internal error (#1898)", async () => {
const host = build({
atlas: {
createBucketItem: vi.fn(() => {
throw new BucketItemExistsError();
}),
},
});
expect(err(await host.dispatch(req('atlas.createBucketItem', { input: { name: 'Kyoto' } }), 42))).toMatchObject({
code: 'BAD_PARAMS',
message: 'bucket item already exists',
});
});
it('ADDONERR-005 vacay insists on an ISO date', async () => {
const host = build();
expect(err(await host.dispatch(req('vacay.toggleEntry', { date: '01.01.2027' }), 42)).message).toBe(
'date must be YYYY-MM-DD',
);
expect(err(await host.dispatch(req('vacay.toggleCompanyHoliday', { date: 42 }), 42)).message).toBe(
'date must be YYYY-MM-DD',
);
});
it('ADDONERR-006 journal needs an entry_date and a journal title', async () => {
const host = build();
expect(err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: {} }), 42)).message).toBe(
'entry_date is required',
);
expect(err(await host.dispatch(req('journal.createJourney', { input: { title: ' ' } }), 42)).message).toBe(
'journal title is required',
);
});
it('ADDONERR-007 an unreadable or uneditable journey is RESOURCE_FORBIDDEN', async () => {
const host = build({
journey: {
listEntries: vi.fn(() => null),
createEntry: vi.fn(() => null),
updateEntry: vi.fn(() => null),
deleteEntry: vi.fn(() => false),
deleteJourney: vi.fn(() => false),
},
});
expect(err(await host.dispatch(req('journal.getEntries', { journeyId: 7 }), 42)).message).toBe(
'no access to journey 7',
);
expect(
err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }), 42))
.message,
).toBe('no editable journey 7 for this user');
expect(err(await host.dispatch(req('journal.updateEntry', { entryId: 3, input: {} }), 42)).message).toBe(
'no editable journal entry 3 for this user',
);
expect(err(await host.dispatch(req('journal.deleteEntry', { entryId: 3 }), 42)).message).toBe(
'no editable journal entry 3 for this user',
);
expect(err(await host.dispatch(req('journal.deleteJourney', { journeyId: 7 }), 42)).message).toBe(
'no deletable journal 7 for this user',
);
});
it('ADDONERR-008 collections map the service status onto the RPC error taxonomy', async () => {
// 403/404 become RESOURCE_FORBIDDEN, 400/409 become BAD_PARAMS. Without this the
// plugin would see an opaque HOST_ERROR and could not tell the cases apart.
const forbidden = build({
collections: {
createCollection: vi.fn(() => {
throw statusError(403, 'not your collection');
}),
},
});
expect(err(await forbidden.dispatch(req('collections.create', { input: { name: 'C' } }), 42))).toMatchObject({
code: 'RESOURCE_FORBIDDEN',
message: 'not your collection',
});
const missing = build({
collections: {
updateCollection: vi.fn(() => {
throw statusError(404, 'no such collection');
}),
},
});
expect(err(await missing.dispatch(req('collections.update', { id: 1, input: { name: 'C' } }), 42))).toMatchObject({
code: 'RESOURCE_FORBIDDEN',
});
const conflict = build({
collections: {
savePlace: vi.fn(() => {
throw statusError(409, 'already saved');
}),
},
});
expect(
err(
await conflict.dispatch(
req('collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }),
42,
),
),
).toMatchObject({ code: 'BAD_PARAMS', message: 'already saved' });
const badRequest = build({
collections: {
copyToTrip: vi.fn(() => {
throw statusError(400, 'nothing to copy');
}),
},
});
expect(
err(await badRequest.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }), 42)),
).toMatchObject({ code: 'BAD_PARAMS' });
});
it('ADDONERR-009 an untagged collections failure stays a HOST_ERROR', async () => {
// Only the known statuses are translated; anything else keeps its own taxonomy
// rather than being dressed up as a permission problem.
const host = build({
collections: {
deletePlace: vi.fn(() => {
throw new Error('disk on fire');
}),
},
});
expect(err(await host.dispatch(req('collections.deletePlace', { placeId: 1 }), 42))).toMatchObject({
code: 'HOST_ERROR',
message: 'disk on fire',
});
});
it('ADDONERR-009b a refused collections delete is ForbiddenResource, not {deleted: true}', async () => {
// deletePlace is async, so a status-tagged rejection has to be awaited before the
// handler decides what to reply — an un-awaited call would let this rejection
// become an unhandled promise rejection while the RPC still answered {deleted: true}.
const host = build({
collections: { deletePlace: vi.fn(() => Promise.reject(statusError(403, 'not your collection'))) },
});
const res = await host.dispatch(req('collections.deletePlace', { placeId: 1 }), 42);
expect(res.ok).toBe(false);
expect(err(res)).toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'not your collection' });
});
it('ADDONERR-010 collections reject a payload the shared schema refuses', async () => {
const host = build();
expect(err(await host.dispatch(req('collections.create', { input: {} }), 42)).code).toBe('BAD_PARAMS');
expect(err(await host.dispatch(req('collections.update', { id: 1, input: { name: 42 } }), 42)).code).toBe(
'BAD_PARAMS',
);
expect(err(await host.dispatch(req('collections.savePlace', { input: {} }), 42)).code).toBe('BAD_PARAMS');
expect(
err(await host.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [] } }), 42)).code,
).toBe('BAD_PARAMS');
});
it('ADDONERR-010b an update or savePlace with only some fields still passes', async () => {
const updateCollection = vi.fn(() => ({}));
const host = build({ collections: { updateCollection } });
await host.dispatch(req('collections.update', { id: 1, input: { description: 'only this' } }), 42);
expect(updateCollection).toHaveBeenCalled();
});
it('ADDONERR-010c a journal entry update carries a non-object input through asPayload', async () => {
const updateEntry = vi.fn(() => ({ id: 1 }));
const host = build({ journey: { updateEntry } });
// asPayload wraps a non-object in { value }, which is the published contract.
await host.dispatch(req('journal.updateEntry', { entryId: 1, input: 'note' }), 42);
expect(updateEntry).toHaveBeenCalledWith(1, 42, { value: 'note' });
});
it('ADDONERR-010d a company holiday without a note passes undefined, not an empty string', async () => {
const toggleCompanyHoliday = vi.fn(() => ({}));
const host = build({ vacay: { toggleCompanyHoliday } });
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01' }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined, undefined);
});
it('ADDONERR-010e a collab message without a replyTo passes null', async () => {
const createMessage = vi.fn(() => ({ error: null, message: {} }));
const host = build({ collab: { createMessage } });
await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42);
expect(createMessage).toHaveBeenCalledWith('1', 42, 'hi', null);
await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi', replyTo: 5 }), 42);
expect(createMessage).toHaveBeenLastCalledWith('1', 42, 'hi', 5);
});
it('ADDONERR-010f listMessages passes the before cursor only when given', async () => {
const listMessages = vi.fn(() => []);
const host = build({ collab: { listMessages } });
await host.dispatch(req('collab.listMessages', { tripId: 1 }), 42);
expect(listMessages).toHaveBeenCalledWith(1, undefined);
await host.dispatch(req('collab.listMessages', { tripId: 1, before: 9 }), 42);
expect(listMessages).toHaveBeenLastCalledWith(1, 9);
});
it('ADDONERR-011 a region without a name falls back to its code', async () => {
const markRegion = vi.fn();
const host = build({ atlas: { markRegion } });
await host.dispatch(req('atlas.markRegion', { regionCode: 'jp-13', countryCode: 'jp' }), 42);
// Codes are upper-cased on the way in, and the name defaults to the code.
expect(markRegion).toHaveBeenCalledWith(42, 'JP-13', 'jp-13', 'JP');
});
});