refactor: e2e tests (#30870)
This commit is contained in:
1 parent
c63824bcea
commit
7cd0a7d30c
32 files changed
+618
-648
No files matched your search
@@ -10,6 +10,61 @@ const UNAUTHENTICATED_ADMIN_ROUTES = new Set([
|
||||
'POST admin/database-backups/start-restore',
|
||||
]);
|
||||
|
||||
/** Admin-only routes that live outside `admin/`, i.e. `@Authenticated({ admin: true })` */
|
||||
const ADMIN_ROUTES = new Set([
|
||||
'DELETE libraries/:id',
|
||||
'DELETE queues/:name/jobs',
|
||||
'DELETE server/license',
|
||||
'GET jobs',
|
||||
'GET libraries',
|
||||
'GET libraries/:id',
|
||||
'GET libraries/:id/statistics',
|
||||
'GET queues',
|
||||
'GET queues/:name',
|
||||
'GET queues/:name/jobs',
|
||||
'GET server/license',
|
||||
'GET server/statistics',
|
||||
'GET system-config',
|
||||
'GET system-config/defaults',
|
||||
'GET system-config/storage-template-options',
|
||||
'GET system-metadata/admin-onboarding',
|
||||
'GET system-metadata/reverse-geocoding-state',
|
||||
'GET system-metadata/version-check-state',
|
||||
'PATCH libraries/:id',
|
||||
'POST jobs',
|
||||
'POST libraries',
|
||||
'POST libraries/:id/scan',
|
||||
'POST libraries/:id/validate',
|
||||
'POST system-metadata/admin-onboarding',
|
||||
'PUT jobs/:name',
|
||||
'PUT libraries/:id',
|
||||
'PUT queues/:name',
|
||||
'PUT server/license',
|
||||
'PUT system-config',
|
||||
]);
|
||||
|
||||
/** Routes a shared link (`?key=`) is allowed to reach, i.e. `@Authenticated({ sharedLink: true })` */
|
||||
const SHARED_LINK_ROUTES = new Set([
|
||||
'DELETE assets/:id/video/stream/:sessionId',
|
||||
'GET albums/:id',
|
||||
'GET albums/:id/map-markers',
|
||||
'GET assets/:id',
|
||||
'GET assets/:id/original',
|
||||
'GET assets/:id/thumbnail',
|
||||
'GET assets/:id/video/playback',
|
||||
'GET assets/:id/video/stream/:sessionId/:variantIndex/:filename',
|
||||
'GET assets/:id/video/stream/:sessionId/:variantIndex/playlist.m3u8',
|
||||
'GET assets/:id/video/stream/main.m3u8',
|
||||
'GET shared-links/me',
|
||||
'GET timeline/bucket',
|
||||
'GET timeline/buckets',
|
||||
'POST assets',
|
||||
'POST download/archive',
|
||||
'POST download/info',
|
||||
'POST search/metadata',
|
||||
'POST shared-links/login',
|
||||
]);
|
||||
|
||||
const isAdminPermission = (permission: AuthenticatedOptions['permission']) =>
|
||||
typeof permission === 'string' && permission.startsWith('admin');
|
||||
|
||||
@@ -45,9 +100,9 @@ const getRoutes = () => {
|
||||
|
||||
describe('controllers', () => {
|
||||
const routes = getRoutes();
|
||||
const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/'));
|
||||
|
||||
it('should only allow non-admin access to bootstrap routes under admin/', () => {
|
||||
const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/'));
|
||||
const reachableByNonAdmins = adminRoutes.filter((route) => !route.auth?.admin).map((route) => route.id);
|
||||
|
||||
expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES);
|
||||
@@ -59,6 +114,20 @@ describe('controllers', () => {
|
||||
expect(undeclared).toEqual([]);
|
||||
});
|
||||
|
||||
it('should only allow shared link access to expected routes', () => {
|
||||
const sharedLinkRoutes = routes.filter((route) => route.auth?.sharedLink).map((route) => route.id);
|
||||
|
||||
expect(new Set(sharedLinkRoutes)).toEqual(SHARED_LINK_ROUTES);
|
||||
});
|
||||
|
||||
it('should only require admin access on expected routes outside /admin', () => {
|
||||
const adminRoutes = routes
|
||||
.filter((route) => route.auth?.admin && route.path !== 'admin' && !route.path.startsWith('admin/'))
|
||||
.map((route) => route.id);
|
||||
|
||||
expect(new Set(adminRoutes)).toEqual(ADMIN_ROUTES);
|
||||
});
|
||||
|
||||
it('should require admin access for routes with an admin permission', () => {
|
||||
const offenders = routes
|
||||
.filter((route) => isAdminPermission(route.auth?.permission) && !route.auth?.admin)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { JobController } from 'src/controllers/job.controller';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { QueueService } from 'src/services/queue.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(JobController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(JobService);
|
||||
const queueService = mockBaseService(QueueService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(JobController, [
|
||||
{ provide: JobService, useValue: service },
|
||||
{ provide: QueueService, useValue: queueService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
queueService.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('PUT /jobs/:name', () => {
|
||||
it('should require a valid queue name', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/jobs/invalid')
|
||||
.send({ command: 'start', force: false });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(queueService.runCommandLegacy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid command', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/jobs/metadataExtraction')
|
||||
.send({ command: 'invalid', force: false });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(queueService.runCommandLegacy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,28 +20,6 @@ describe(LibraryController.name, () => {
|
||||
|
||||
const id = factory.uuid();
|
||||
|
||||
describe('authentication', () => {
|
||||
const routes = [
|
||||
{ method: 'get', path: '/libraries' },
|
||||
{ method: 'post', path: '/libraries' },
|
||||
{ method: 'get', path: `/libraries/${id}` },
|
||||
{ method: 'put', path: `/libraries/${id}` },
|
||||
{ method: 'patch', path: `/libraries/${id}` },
|
||||
{ method: 'delete', path: `/libraries/${id}` },
|
||||
{ method: 'post', path: `/libraries/${id}/validate` },
|
||||
{ method: 'get', path: `/libraries/${id}/statistics` },
|
||||
{ method: 'post', path: `/libraries/${id}/scan` },
|
||||
] as const;
|
||||
|
||||
it.each(routes)('$method $path should be an admin route', async ({ method, path }) => {
|
||||
await request(ctx.getHttpServer())[method](path).send({});
|
||||
|
||||
expect(ctx.authenticate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /libraries', () => {
|
||||
it('should require an owner id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { MapController } from 'src/controllers/map.controller';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(MapController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(MapService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(MapController, [{ provide: MapService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /map/reverse-geocode', () => {
|
||||
it('should require a lat', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lon: 123 });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(service.reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a lat that is a number', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get('/map/reverse-geocode')
|
||||
.query({ lat: 'abc', lon: 123.456 });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(service.reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a lat that is in range', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get('/map/reverse-geocode')
|
||||
.query({ lat: 91, lon: 123.456 });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
|
||||
);
|
||||
expect(service.reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a lon', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lat: 75 });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(service.reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(OAuthController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(AuthService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(OAuthController, [{ provide: AuthService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /oauth/authorize', () => {
|
||||
it('should require a redirect uri', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/authorize').send({});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(service.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /oauth/callback', () => {
|
||||
it('should require a url', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['url'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow an empty url', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({ url: '' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['url'], message: 'Too small: expected string to have >=1 characters' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /oauth/backchannel-logout', () => {
|
||||
it('should require a logout token', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/backchannel-logout').send({});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,15 @@ describe(SharedLinkController.name, () => {
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||
});
|
||||
|
||||
it('should require an albumId for share type Album', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/shared-links')
|
||||
.send({ type: SharedLinkType.Album });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }]));
|
||||
expect(service.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow an albumId for share type Individual', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/shared-links')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
|
||||
import { SystemMetadataService } from 'src/services/system-metadata.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SystemMetadataController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(SystemMetadataService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SystemMetadataController, [{ provide: SystemMetadataService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /system-metadata/admin-onboarding', () => {
|
||||
it('should require isOnboarded', async () => {
|
||||
const { status } = await request(ctx.getHttpServer()).post('/system-metadata/admin-onboarding').send({});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(service.updateAdminOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TrashController } from 'src/controllers/trash.controller';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(TrashController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(TrashService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(TrashController, [{ provide: TrashService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /trash/restore/assets', () => {
|
||||
it('should require asset ids', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/trash/restore/assets').send({});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['ids'], message: 'Invalid input: expected array, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(service.restoreAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require valid asset ids', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/trash/restore/assets')
|
||||
.send({ ids: ['invalid'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(service.restoreAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user