Files
TREK/server/tests/unit/nest/share.controller.test.ts
T
Maurice 8c8cc2a727 Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.

The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.

Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.

The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.

Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.

Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
2026-08-11 11:15:32 +02:00

143 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Response } from 'express';
const { createReadStream } = vi.hoisted(() => ({ createReadStream: vi.fn() }));
vi.mock('node:fs', () => ({ createReadStream }));
import { TripShareController, SharedController } from '../../../src/nest/share/share.controller';
import type { ShareService } from '../../../src/nest/share/share.service';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
function svc(o: Partial<ShareService> = {}): ShareService {
return {
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
canManage: vi.fn().mockReturnValue(true),
...o,
} as unknown as ShareService;
}
function res() {
const r = { statusCode: 200, status: vi.fn((c: number) => { r.statusCode = c; return r; }) };
return r as unknown as Response & { statusCode: number };
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('TripShareController', () => {
it('POST 404 without access, 403 without share_manage', () => {
expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', {}, res()))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()))).toEqual({ status: 403, body: { error: 'No permission' } });
});
it('POST answers 201 on create, 200 on update', () => {
const createdRes = res();
const c1 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: true }) } as Partial<ShareService>));
expect(c1.create(user, '5', { share_map: true }, createdRes)).toEqual({ token: 't' });
expect(createdRes.statusCode).toBe(201);
const updatedRes = res();
const c2 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: false }) } as Partial<ShareService>));
expect(c2.create(user, '5', {}, updatedRes)).toEqual({ token: 't' });
expect(updatedRes.statusCode).toBe(200);
});
it('GET 404 without access, 403 without share_manage, returns info or a null token', () => {
expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
// Reading returns the token itself, so it needs the same permission as
// creating it — a member with plain trip access must not get a copy.
const get = vi.fn();
expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial<ShareService>)).get(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } });
expect(get).not.toHaveBeenCalled();
expect(new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial<ShareService>)).get(user, '5')).toEqual({ token: 't' });
expect(new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).get(user, '5')).toEqual({ token: null });
});
it('DELETE 403 without share_manage, else removes', () => {
expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } });
const remove = vi.fn();
expect(new TripShareController(svc({ remove } as Partial<ShareService>)).remove(user, '5')).toEqual({ success: true });
expect(remove).toHaveBeenCalledWith('5');
});
});
describe('SharedController', () => {
it('404 for an invalid token, else returns the snapshot', () => {
expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } });
expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial<ShareService>)).read('tok')).toEqual({ trip: { id: 9 } });
});
describe('place-photo proxy', () => {
function photoRes() {
const r = {
statusCode: 200,
headersSent: false,
status: vi.fn(function (this: unknown, c: number) { (r as { statusCode: number }).statusCode = c; return r; }),
json: vi.fn(),
set: vi.fn(),
type: vi.fn(),
};
return r as unknown as Response & { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
}
beforeEach(() => createReadStream.mockReset());
function controller(path: string | null) {
return new SharedController(svc({ getSharedPlacePhotoPath: vi.fn().mockReturnValue(path) } as Partial<ShareService>));
}
it('404 without streaming when the photo is not cached for the token', () => {
const res = photoRes();
controller(null).placePhotoBytes('tok', 'p1', res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
expect(createReadStream).not.toHaveBeenCalled();
});
it('streams the cached file with image/jpeg + an immutable cache header on a hit', () => {
const stream = { on: vi.fn().mockReturnThis(), pipe: vi.fn() };
createReadStream.mockReturnValue(stream);
const res = photoRes();
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
expect(res.type).toHaveBeenCalledWith('image/jpeg');
expect(createReadStream).toHaveBeenCalledWith('/cache/p1.jpg');
expect(stream.pipe).toHaveBeenCalledWith(res);
});
it('falls back to 404 when the read stream errors before headers were sent', () => {
let onError: () => void = () => {};
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
createReadStream.mockReturnValue(stream);
const res = photoRes();
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
onError();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
});
it('does not re-send a 404 when the stream errors after headers were flushed', () => {
let onError: () => void = () => {};
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
createReadStream.mockReturnValue(stream);
const res = photoRes();
(res as { headersSent: boolean }).headersSent = true;
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
onError();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
});
});