mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
4d81180fc1
Expectations follow createAssignment's options, the stage_ends column, the editor's trip access and the broadcasts of the take-over. Refusals in the run and ski e2e tests go one by one so supertest does not reset a connection under another request. A parity test keeps the source hint enums in step with the contract.
296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
/**
|
|
* Whole tours kept in a collection (LIB-03), through the real JwtAuthGuard,
|
|
* AddonGuard and TripAccessGuard against a migrated and seeded temp SQLite db,
|
|
* every answer checked against its response contract. The permission check is
|
|
* a container spy and the broadcasts go to a FakeRealtimeService.
|
|
*
|
|
* Fixtures: user 1 owns trips 5 and 6 and the collection, user 2 is a member
|
|
* of trip 5 whom the spy denies 'place_edit' and a viewer of the collection,
|
|
* user 3 has no access to either.
|
|
*/
|
|
import { CollectionTours } from '../../src/db/entities/CollectionTours.entity';
|
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
|
import { ToursModule } from '../../src/nest/tours/tours.module';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { addCollectionMember, makeCollection } from '../helpers/factories/collections';
|
|
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { addTripMember } from '../helpers/factories/trips';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
|
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
const { journeyHooks } = vi.hoisted(() => ({
|
|
journeyHooks: {
|
|
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}));
|
|
|
|
const db = createSnapshotTestDb();
|
|
const realtime = new FakeRealtimeService();
|
|
|
|
let orm: TestOrm;
|
|
let checkPermission: MockInstance;
|
|
let collectionId: number;
|
|
|
|
const OWNER = 1;
|
|
const MEMBER = 2;
|
|
const OUTSIDER = 3;
|
|
|
|
const GEOMETRY = [
|
|
[47, 11, 600],
|
|
[47.01, 11.01, 700],
|
|
[47.02, 11.02, 650],
|
|
];
|
|
const tourBody = (overrides: Record<string, unknown> = {}) => ({
|
|
name: 'Ridge walk',
|
|
route_geometry: GEOMETRY,
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
|
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Saddle' },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
|
],
|
|
max_hiking_difficulty: 3,
|
|
duration_seconds: 3600,
|
|
...overrides,
|
|
});
|
|
|
|
describe('Collection tours e2e (real guards + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
RealtimeModule,
|
|
ToursModule,
|
|
],
|
|
})
|
|
.overrideProvider(RealtimeService)
|
|
.useValue(realtime)
|
|
.overrideProvider(JourneyDomainService)
|
|
.useValue(journeyHooks)
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
|
|
|
const createTour = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
|
const res = await request(server).post(`/api/trips/${tripId}/tours`).set(as(OWNER)).send(body);
|
|
expect(res.status).toBe(201);
|
|
return res.body.tour.place_id as number;
|
|
};
|
|
|
|
const keep = async (placeId: number, userId = OWNER, extra: Record<string, unknown> = {}) =>
|
|
request(server)
|
|
.post(`/api/addons/collections/${collectionId}/tours`)
|
|
.set(as(userId))
|
|
.send({ source_trip_id: 5, source_place_id: placeId, ...extra });
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(db);
|
|
// Pinned ids: the session cookies sign for exactly these users.
|
|
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
|
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
|
|
await makeUser(orm, {
|
|
id: 3,
|
|
username: 'outsider',
|
|
email: 'outsider@example.test',
|
|
role: 'user',
|
|
password_version: 0,
|
|
});
|
|
await insertRows(orm, Trips, [
|
|
{ id: 5, user: 1, title: 'Alps' },
|
|
{ id: 6, user: 1, title: 'Other' },
|
|
]);
|
|
await addTripMember(orm, 5, 2);
|
|
const list = await makeCollection(orm, OWNER, { name: 'Hikes' });
|
|
collectionId = list.id;
|
|
await addCollectionMember(orm, collectionId, MEMBER, { role: 'viewer' });
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
checkPermission.mockImplementation(
|
|
async (action: string, _role: string, _owner: number, userId: number) =>
|
|
!(action === 'place_edit' && userId === MEMBER),
|
|
);
|
|
await setAddonEnabled(orm, 'tours', true);
|
|
await setAddonEnabled(orm, 'collections', true);
|
|
realtime.broadcastMock.mockClear();
|
|
realtime.broadcastToUserMock.mockClear();
|
|
await deleteRows(orm, CollectionTours);
|
|
await deleteRows(orm, DayAssignments);
|
|
await deleteRows(orm, Places);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
it('keeps a tour, lists it for every member and shows what taking it over asks about', async () => {
|
|
const source = await createTour();
|
|
const kept = await keep(source, OWNER, { name: 'Ridge, kept' });
|
|
expect(kept.status).toBe(201);
|
|
expect(kept.body.tour).toMatchObject({
|
|
name: 'Ridge, kept',
|
|
tour_type: 'hike',
|
|
has_line: true,
|
|
waypoint_count: 3,
|
|
source: { tripTitle: 'Alps', tourName: 'Ridge walk' },
|
|
saved_by: 'owner',
|
|
});
|
|
expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(
|
|
MEMBER,
|
|
{ type: 'collections:updated', collectionId },
|
|
undefined,
|
|
);
|
|
|
|
const listed = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(MEMBER));
|
|
expect(listed.status).toBe(200);
|
|
expect(listed.body.tours.map((tour: { id: number }) => tour.id)).toEqual([kept.body.tour.id]);
|
|
|
|
const detail = await request(server)
|
|
.get(`/api/addons/collections/${collectionId}/tours/${kept.body.tour.id}`)
|
|
.set(as(MEMBER));
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
|
|
expect(detail.body.points).toEqual([
|
|
{ role: 'start', name: null, shaping: false },
|
|
{ role: 'via', name: 'Saddle', shaping: false },
|
|
{ role: 'end', name: null, shaping: false },
|
|
]);
|
|
// The member sees the trip they belong to, without the right to add a tour to it.
|
|
expect(detail.body.trips).toEqual([{ id: 5, title: 'Alps', start_date: null, end_date: null, can_add: false }]);
|
|
});
|
|
|
|
it('takes a kept tour into a trip with its points, or with its line only, and changes neither source', async () => {
|
|
const source = await createTour();
|
|
const kept = (await keep(source)).body.tour as { id: number };
|
|
// A later change to the source does not reach the kept tour.
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${source}`)
|
|
.set(as(OWNER))
|
|
.send(tourBody({ name: 'Changed later' }));
|
|
// Making the source and changing it told trip 5; from here on only the trip it is taken into hears of it.
|
|
realtime.broadcastMock.mockClear();
|
|
|
|
const full = await request(server)
|
|
.post('/api/trips/6/tours/from-collection')
|
|
.set(as(OWNER))
|
|
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true });
|
|
expect(full.status).toBe(201);
|
|
expect(full.body.tour).toMatchObject({ name: 'Ridge walk', planned: false, revision: 1 });
|
|
const roles = full.body.waypoints.map((point: { role: string; name?: string }) => [point.role, point.name ?? null]);
|
|
expect(roles).toEqual([
|
|
['start', null],
|
|
['via', 'Saddle'],
|
|
['end', null],
|
|
]);
|
|
expect(full.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
|
|
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
|
|
|
|
const bare = await request(server)
|
|
.post('/api/trips/6/tours/from-collection')
|
|
.set(as(OWNER))
|
|
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: false, name: 'Bare' });
|
|
expect(bare.status).toBe(201);
|
|
expect(bare.body.tour).toMatchObject({ name: 'Bare', has_waypoints: false });
|
|
expect(await countRows(orm, TourWaypoints, { place: bare.body.tour.place_id })).toBe(0);
|
|
expect((await findRow(orm, Places, { id: bare.body.tour.place_id }))?.route_geometry).toBe(
|
|
JSON.stringify(GEOMETRY),
|
|
);
|
|
expect(await countRows(orm, Places, { trip: 6 })).toBe(2);
|
|
expect(await countRows(orm, DayAssignments)).toBe(0);
|
|
expect(await countRows(orm, CollectionTours)).toBe(1);
|
|
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Changed later');
|
|
});
|
|
|
|
it('refuses a viewer keeping, a stranger reading, a trip without place_edit and an unsaid choice about the points', async () => {
|
|
const source = await createTour();
|
|
expect((await keep(source, MEMBER)).status).toBe(403);
|
|
const kept = (await keep(source)).body.tour as { id: number };
|
|
const stranger = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OUTSIDER));
|
|
expect(stranger.status).toBe(404);
|
|
expect(stranger.body).toEqual({ error: 'Collection not found' });
|
|
|
|
const body = { collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true };
|
|
const takeOver = (tripId: number, userId: number) =>
|
|
request(server).post(`/api/trips/${tripId}/tours/from-collection`).set(as(userId)).send(body);
|
|
expect((await takeOver(5, MEMBER)).status).toBe(403);
|
|
expect((await takeOver(6, OUTSIDER)).status).toBe(404);
|
|
const unsaid = await request(server)
|
|
.post('/api/trips/6/tours/from-collection')
|
|
.set(as(OWNER))
|
|
.send({ collection_id: collectionId, collection_tour_id: kept.id });
|
|
expect(unsaid.status).toBe(400);
|
|
const wrongList = await request(server)
|
|
.post('/api/trips/6/tours/from-collection')
|
|
.set(as(OWNER))
|
|
.send({ ...body, collection_id: collectionId + 1000 });
|
|
expect(wrongList.status).toBe(404);
|
|
expect(await countRows(orm, Places, { trip: 6 })).toBe(0);
|
|
});
|
|
|
|
it('removes a kept tour for the owner only, and answers malformed ids, sessions and addons as the guards do', async () => {
|
|
const source = await createTour();
|
|
const kept = (await keep(source)).body.tour as { id: number };
|
|
const path = `/api/addons/collections/${collectionId}/tours/${kept.id}`;
|
|
expect((await request(server).delete(path).set(as(MEMBER))).status).toBe(403);
|
|
const removed = await request(server).delete(path).set(as(OWNER));
|
|
expect(removed.status).toBe(200);
|
|
expect(removed.body).toEqual({ success: true });
|
|
expect((await request(server).delete(path).set(as(OWNER))).status).toBe(404);
|
|
expect((await request(server).get(`/api/addons/collections/abc/tours`).set(as(OWNER))).status).toBe(404);
|
|
const malformed = await request(server).get(`/api/addons/collections/${collectionId}/tours/x`).set(as(OWNER));
|
|
expect(malformed.status).toBe(404);
|
|
expect((await request(server).get(`/api/addons/collections/${collectionId}/tours`)).status).toBe(401);
|
|
await setAddonEnabled(orm, 'collections', false);
|
|
const noCollections = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
|
|
expect(noCollections.body).toEqual({ error: 'Collections addon is not enabled' });
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
const noTours = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
|
|
expect(noTours.status).toBe(404);
|
|
expect(noTours.body).toEqual({ error: 'Tours addon is not enabled' });
|
|
});
|
|
});
|