mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
c956fe92ba
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database.
168 lines
8.8 KiB
TypeScript
168 lines
8.8 KiB
TypeScript
/**
|
|
* The Tours permission contract, run through the real guards with mocked
|
|
* collaborators (no DB): reads need trip access only, writes need
|
|
* 'place_edit' and never 'day_edit', an inaccessible trip is a 404 before any
|
|
* permission check, and the addon and JWT refusals stop a request before the
|
|
* trip is resolved. The GPX import answers the same matrix from its handler.
|
|
*/
|
|
import 'reflect-metadata';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { HttpException, type ExecutionContext } from '@nestjs/common';
|
|
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
|
import { Reflector } from '@nestjs/core';
|
|
import type { EntityManager } from '@mikro-orm/core';
|
|
|
|
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
|
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
|
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../../src/config', () => ({
|
|
ENCRYPTION_KEY: 'test-only-inert-key',
|
|
JWT_SECRET: 'test-only-inert-secret',
|
|
updateJwtSecret: vi.fn(),
|
|
}));
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: new Proxy({}, {
|
|
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
|
}),
|
|
}));
|
|
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
|
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
|
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
|
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
|
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
|
|
|
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
|
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
|
import { TripAccessGuard, TRIP_PERMISSION_KEY, TRIP_REQUEST_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import type { PlacesService } from '../../../src/nest/places/places.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
|
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
|
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
|
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const matrix = [[false, false], [true, false], [false, true], [true, true]] as const;
|
|
const user = { id: 2, role: 'user' } as User;
|
|
const trip = { id: 7, user_id: 1 };
|
|
|
|
/** Trip 7 is reachable for user 2; it belongs to user 1, so every check is a shared-trip check. */
|
|
function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') {
|
|
const request = { params: { tripId }, user };
|
|
const access = vi.fn(async (id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined);
|
|
const permission = vi.fn(async (action: string) => action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign);
|
|
const context = {
|
|
switchToHttp: () => ({ getRequest: () => request }),
|
|
getHandler: () => ToursController.prototype[handler],
|
|
getClass: () => ToursController,
|
|
} as unknown as ExecutionContext;
|
|
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
|
|
const guard = new TripAccessGuard(em, { checkPermission: permission } as unknown as PermissionsService, new Reflector());
|
|
return { request, access, permission, context, guard };
|
|
}
|
|
|
|
/** The import handler's own checks, with the same access and permission doubles. */
|
|
function importFixture(canEdit: boolean, canAssign: boolean) {
|
|
const permission = vi.fn(async (action: string, ..._context: unknown[]) => action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign);
|
|
const access = vi.fn(async (id: string, userId: number) => id === '7' && userId === 2 ? trip : undefined);
|
|
const places = {
|
|
verifyTripAccess: access,
|
|
canEdit: vi.fn(async (t: typeof trip, u: User) => permission('place_edit', u.role, t.user_id, u.id, t.user_id !== u.id)),
|
|
};
|
|
const importGpxAsTour = vi.fn(async () => ({ tours: [{ place_id: 42 }], caution: false, skipped: 0 }));
|
|
const controller = new ToursImportController(
|
|
{ importGpxAsTour } as unknown as ToursService,
|
|
places as unknown as PlacesService,
|
|
{ isAddonEnabled: vi.fn(async () => true) } as unknown as AddonsService,
|
|
);
|
|
return { controller, access, permission, importGpxAsTour };
|
|
}
|
|
|
|
async function expectStatus(action: () => Promise<unknown>, status: number) {
|
|
try {
|
|
await action();
|
|
expect.fail('Expected rejection');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(HttpException);
|
|
expect((error as HttpException).getStatus()).toBe(status);
|
|
}
|
|
}
|
|
|
|
const file = { buffer: Buffer.from('gpx'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
|
|
|
describe('Tours permission contract (mock-only, no DB)', () => {
|
|
it('does not access the legacy global database initializer', () => {
|
|
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retains addon/JWT/trip guard order and exact write metadata', () => {
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
|
for (const handler of ['create', 'update'] as const) {
|
|
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBe('place_edit');
|
|
}
|
|
for (const handler of ['list', 'detail'] as const) {
|
|
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it.each(matrix)('edit=%s assign=%s permits reads and only place-edit writes', async (canEdit, canAssign) => {
|
|
for (const handler of ['list', 'detail'] as const) {
|
|
const test = fixture(handler, canEdit, canAssign);
|
|
expect(await test.guard.canActivate(test.context)).toBe(true);
|
|
expect(test.permission).not.toHaveBeenCalled();
|
|
expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(trip);
|
|
}
|
|
for (const handler of ['create', 'update'] as const) {
|
|
const test = fixture(handler, canEdit, canAssign);
|
|
if (canEdit) expect(await test.guard.canActivate(test.context)).toBe(true);
|
|
else await expectStatus(() => test.guard.canActivate(test.context), 403);
|
|
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
|
}
|
|
});
|
|
|
|
it.each(matrix)('edit=%s assign=%s: the GPX import handler requires only place_edit', async (canEdit, canAssign) => {
|
|
const test = importFixture(canEdit, canAssign);
|
|
if (canEdit) {
|
|
expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] });
|
|
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
|
} else {
|
|
await expectStatus(() => test.controller.importGpx(user, '7', file), 403);
|
|
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
|
}
|
|
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
|
});
|
|
|
|
it.each(['list', 'detail', 'create', 'update'] as const)('%s rejects inaccessible route trips before any write permission check', async handler => {
|
|
const test = fixture(handler, true, true, '8');
|
|
await expectStatus(() => test.guard.canActivate(test.context), 404);
|
|
expect(test.access).toHaveBeenCalledWith(8, 2);
|
|
expect(test.permission).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('importGpx rejects an inaccessible route trip before any permission check', async () => {
|
|
const test = importFixture(true, true);
|
|
await expectStatus(() => test.controller.importGpx(user, '8', file), 404);
|
|
expect(test.access).toHaveBeenCalledWith('8', 2);
|
|
expect(test.permission).not.toHaveBeenCalled();
|
|
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves addon and JWT refusal without reaching trip resolution', async () => {
|
|
const test = fixture('create', true, true);
|
|
const addon = new AddonGuard({ isAddonEnabled: vi.fn(async () => false) } as unknown as AddonsService, new Reflector());
|
|
await expectStatus(() => addon.canActivate(test.context), 404);
|
|
const jwt = new JwtAuthGuard({ getRepository: vi.fn() } as unknown as EntityManager);
|
|
vi.mocked(extractToken).mockReturnValue(null);
|
|
await expectStatus(() => jwt.canActivate(test.context), 401);
|
|
vi.mocked(extractToken).mockReturnValue('token');
|
|
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
|
await expectStatus(() => jwt.canActivate(test.context), 401);
|
|
expect(test.access).not.toHaveBeenCalled();
|
|
});
|
|
});
|