mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
7078ac7a4d
Client CI was red on this branch while dev was green, and no client file had changed. The 536 test files all passed; the job failed on three unhandled rejections, all `window is not defined` from MDashWidgets, attributed to whichever case happened to be running when they landed. The widget fetches api.frankfurter.dev directly and sets state from both handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so that request was never intercepted: it left the runner, came back after the environment was gone, and React tried to schedule an update against a torn down tree. Load dependent by construction, which is why dev happened to pass. Two halves, because either alone leaves the other broken. The widget now aborts in flight work on unmount and on refetch, and treats an abort as "no one is waiting" rather than as a failure to report. That matters outside the runner too: this is a third party call on a mobile screen a user can leave immediately. The handler set gains the third party endpoints the app calls directly rather than through /api, so no suite reaches the real host. An empty rate list is the shape the widget already handles. Also clears twenty more test files, each at its root cause. Two more were passing for the wrong reason: oidc.service.test.ts built AuthService with an AtlasService in the membership slot, undefined for userCleanup and mailer, and the mailer stub in the tokens slot, the same shift admin.service.test.ts had. And CAL-010 claims to serialise a flight byte for byte, but createReservation ignores reservation_time, so the row is written with a null time and CalendarService drops it: the recorded snapshot is truthful about the data the case builds and silent about the flight it advertises.
176 lines
7.3 KiB
TypeScript
176 lines
7.3 KiB
TypeScript
/**
|
|
* PluginGuards holds the resource gates the decorated *.rpc.ts handlers will use from
|
|
* the first rollout PR onward. Its messages are copied character-for-character from
|
|
* the legacy router, because rpc-host.test.ts asserts them and shipped plugins read
|
|
* them, so these tests pin the exact strings rather than just the refusal.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
|
|
import { BadParams, ForbiddenResource } from '../../../src/nest/plugins/host/rpc-errors';
|
|
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 { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
|
|
|
|
/**
|
|
* The guards read `actingUserId` and nothing else off the context, so the two per-host
|
|
* collaborators are inert here. `plugins` gets real no-op functions rather than a cast,
|
|
* so a guard that ever reached for a peer would hit a callable, not undefined.
|
|
*/
|
|
const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
|
|
pluginId: 'p',
|
|
actingUserId,
|
|
data: {} as PluginRpcContext['data'],
|
|
plugins: {
|
|
call: vi.fn(async () => undefined),
|
|
emit: vi.fn(),
|
|
},
|
|
});
|
|
|
|
/** Trip 1 belongs to user 42 and only user 42 may reach it, mirroring rpc-host.test.ts. */
|
|
function build(overrides: { role?: string | undefined; allow?: boolean; addonOn?: boolean } = {}) {
|
|
const db = {
|
|
canAccessTrip: vi.fn((tripId: number, userId: number) =>
|
|
tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined,
|
|
),
|
|
prepare: vi.fn(() => ({
|
|
get: () => ('role' in overrides ? (overrides.role === undefined ? undefined : { role: overrides.role }) : { role: 'user' }),
|
|
})),
|
|
} as unknown as DatabaseService;
|
|
const permissions = {
|
|
checkPermission: vi.fn(() => overrides.allow ?? true),
|
|
} as unknown as PermissionsService;
|
|
const addons = {
|
|
isAddonEnabled: vi.fn(() => overrides.addonOn ?? true),
|
|
} as unknown as AddonsService;
|
|
return { guards: new PluginGuards(db, permissions, addons), db, permissions, addons };
|
|
}
|
|
|
|
describe('PluginGuards — tripRead', () => {
|
|
it('PGUARD-001 runs the read for a member and hands it the acting user', () => {
|
|
const { guards } = build();
|
|
const read = vi.fn((userId: number) => ({ seenBy: userId }));
|
|
expect(guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
|
|
expect(read).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it('PGUARD-002 a userless context is refused before the read runs', () => {
|
|
const { guards } = build();
|
|
const read = vi.fn();
|
|
expect(() => guards.tripRead({ tripId: 1 }, ctx(undefined), read)).toThrow(
|
|
new ForbiddenResource('trip reads require an authenticated user context'),
|
|
);
|
|
expect(read).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PGUARD-003 a non-member is refused, naming the trip', () => {
|
|
const { guards } = build();
|
|
const read = vi.fn();
|
|
expect(() => guards.tripRead({ tripId: 2 }, ctx(42), read)).toThrow(
|
|
new ForbiddenResource('no access to trip 2'),
|
|
);
|
|
expect(read).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', () => {
|
|
const { guards } = build();
|
|
expect(() => guards.tripRead({}, ctx(42), vi.fn())).toThrow(new BadParams('tripId must be a number'));
|
|
});
|
|
|
|
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', () => {
|
|
const { guards } = build();
|
|
expect(guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('PluginGuards — requireActor', () => {
|
|
it('PGUARD-006 returns the acting user', () => {
|
|
expect(build().guards.requireActor(ctx(42), 'tag')).toBe(42);
|
|
});
|
|
|
|
it('PGUARD-007 appends the word "writes" to the noun, verbatim as the router did', () => {
|
|
expect(() => build().guards.requireActor(ctx(undefined), 'tag')).toThrow(
|
|
new ForbiddenResource('tag writes require an authenticated user context'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('PluginGuards — requireTripEdit and canEditAs', () => {
|
|
it('PGUARD-008 passes when the user may access and edit', () => {
|
|
const { guards, permissions } = build({ allow: true });
|
|
expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).not.toThrow();
|
|
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false);
|
|
});
|
|
|
|
it('PGUARD-009 no access wins over no permission, and names the trip', () => {
|
|
const { guards } = build({ allow: false });
|
|
expect(() => guards.requireTripEdit(2, 42, 'trip_edit')).toThrow(
|
|
new ForbiddenResource('no access to trip 2'),
|
|
);
|
|
});
|
|
|
|
it('PGUARD-010 access without the edit permission is a different message', () => {
|
|
const { guards } = build({ allow: false });
|
|
expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).toThrow(
|
|
new ForbiddenResource('no permission to edit trip 1'),
|
|
);
|
|
});
|
|
|
|
it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', () => {
|
|
expect(build().guards.canEditAs('trip_edit', 2, 42)).toBe(false);
|
|
});
|
|
|
|
it('PGUARD-012 canEditAs returns false when the user row is gone', () => {
|
|
expect(build({ role: undefined }).guards.canEditAs('trip_edit', 1, 42)).toBe(false);
|
|
});
|
|
|
|
it('PGUARD-013 a missing role column falls back to "user"', () => {
|
|
const { guards, permissions } = build({ role: null as unknown as string });
|
|
guards.canEditAs('trip_edit', 1, 42);
|
|
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false);
|
|
});
|
|
|
|
it('PGUARD-014 a non-owner member is flagged as shared', () => {
|
|
const db = {
|
|
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 7 })),
|
|
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }) })),
|
|
} as unknown as DatabaseService;
|
|
const permissions = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
|
|
const guards = new PluginGuards(db, permissions, {} as AddonsService);
|
|
guards.canEditAs('trip_edit', 1, 42);
|
|
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 7, 42, true);
|
|
});
|
|
});
|
|
|
|
describe('PluginGuards — requireAddon', () => {
|
|
it('PGUARD-015 an enabled addon passes', () => {
|
|
expect(() => build({ addonOn: true }).guards.requireAddon('budget', 'costs')).not.toThrow();
|
|
});
|
|
|
|
it('PGUARD-016 a disabled addon is refused with the noun in the message', () => {
|
|
expect(() => build({ addonOn: false }).guards.requireAddon('budget', 'costs')).toThrow(
|
|
new ForbiddenResource('the costs addon is disabled'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('PluginGuards — capStrings', () => {
|
|
it('PGUARD-017 a field within the cap passes', () => {
|
|
expect(() => build().guards.capStrings({ name: 'ok' }, { name: 200 })).not.toThrow();
|
|
});
|
|
|
|
it('PGUARD-018 an oversized field is BAD_PARAMS, naming field and cap', () => {
|
|
expect(() => build().guards.capStrings({ name: 'x'.repeat(201) }, { name: 200 })).toThrow(
|
|
new BadParams('name must be 200 characters or fewer'),
|
|
);
|
|
});
|
|
|
|
it('PGUARD-019 a non-string value is left alone', () => {
|
|
expect(() => build().guards.capStrings({ name: 12345 }, { name: 2 })).not.toThrow();
|
|
});
|
|
|
|
it('PGUARD-020 an absent field is left alone', () => {
|
|
expect(() => build().guards.capStrings({}, { name: 1 })).not.toThrow();
|
|
});
|
|
});
|