From 7cd0a7d30c19b4eae53d1bd0cd6ff414dfd5a98e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 14:31:57 -0400 Subject: [PATCH] refactor: e2e tests (#30870) --- e2e/src/responses.ts | 47 ------ .../server/maintenance.e2e-spec.ts | 25 +--- e2e/src/specs/server/api/album.e2e-spec.ts | 10 -- e2e/src/specs/server/api/api-key.e2e-spec.ts | 45 ------ e2e/src/specs/server/api/asset.e2e-spec.ts | 17 --- e2e/src/specs/server/api/jobs.e2e-spec.ts | 10 +- e2e/src/specs/server/api/map.e2e-spec.ts | 53 ------- e2e/src/specs/server/api/memory.e2e-spec.ts | 44 ------ e2e/src/specs/server/api/oauth.e2e-spec.ts | 36 ----- e2e/src/specs/server/api/server.e2e-spec.ts | 33 ----- e2e/src/specs/server/api/session.e2e-spec.ts | 17 ++- .../specs/server/api/shared-link.e2e-spec.ts | 26 +--- e2e/src/specs/server/api/stack.e2e-spec.ts | 23 +-- .../server/api/system-metadata.e2e-spec.ts | 21 --- e2e/src/specs/server/api/tag.e2e-spec.ts | 137 +----------------- e2e/src/specs/server/api/trash.e2e-spec.ts | 22 --- .../specs/server/api/user-admin.e2e-spec.ts | 64 +------- e2e/src/specs/server/api/user.e2e-spec.ts | 16 +- server/src/controllers/index.spec.ts | 71 ++++++++- server/src/controllers/job.controller.spec.ts | 45 ++++++ .../controllers/library.controller.spec.ts | 22 --- server/src/controllers/map.controller.spec.ts | 66 +++++++++ .../src/controllers/oauth.controller.spec.ts | 71 +++++++++ .../shared-link.controller.spec.ts | 9 ++ .../system-metadata.controller.spec.ts | 28 ++++ .../src/controllers/trash.controller.spec.ts | 44 ++++++ .../specs/services/album.service.spec.ts | 30 +++- .../specs/services/api-key.service.spec.ts | 38 +++++ .../specs/services/asset.service.spec.ts | 24 +++ .../specs/services/memory.service.spec.ts | 63 ++++++++ .../specs/services/stack.service.spec.ts | 44 ++++++ .../medium/specs/services/tag.service.spec.ts | 65 +++++++++ 32 files changed, 618 insertions(+), 648 deletions(-) create mode 100644 server/src/controllers/job.controller.spec.ts create mode 100644 server/src/controllers/map.controller.spec.ts create mode 100644 server/src/controllers/oauth.controller.spec.ts create mode 100644 server/src/controllers/system-metadata.controller.spec.ts create mode 100644 server/src/controllers/trash.controller.spec.ts create mode 100644 server/test/medium/specs/services/stack.service.spec.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 337d101cb4..54cfb814e8 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -1,54 +1,7 @@ import { expect } from 'vitest'; export const errorDto = { - unauthorized: { - message: 'Authentication required', - }, - unauthorizedWithMessage: (message: string) => ({ - message, - }), - forbidden: { - message: expect.any(String), - }, - missingPermission: (permission: string) => ({ - message: `Missing required permission: ${permission}`, - }), - wrongPassword: { - message: 'Wrong password', - }, - invalidToken: { - message: 'Invalid user token', - }, - invalidShareKey: { - message: 'Invalid share key', - }, - passwordRequired: { - message: 'Password required', - }, badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), - validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ - message: 'Validation failed', - errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), - }), - noPermission: { - message: expect.stringContaining('Not found or no'), - }, - incorrectLogin: { - message: 'Incorrect email or password', - }, -}; - -export const deviceDto = { - current: { - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - current: true, - isPendingSyncReset: false, - deviceOS: '', - deviceType: '', - appVersion: null, - }, }; diff --git a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts index f3833ffc00..bea5f58557 100644 --- a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts @@ -1,5 +1,4 @@ import { LoginResponseDto } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; @@ -8,18 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/admin/maintenance', () => { let cookie: string | undefined; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); await utils.resetBackups(admin.accessToken); }); // => outside of maintenance mode - describe('GET ~/server/config', async () => { + describe('GET /server/config', async () => { it('should indicate we are out of maintenance mode', async () => { const { status, body } = await request(app).get('/server/config'); expect(status).toBe(200); @@ -49,24 +46,6 @@ describe('/admin/maintenance', () => { // => enter maintenance mode describe.sequential('POST /', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/admin/maintenance').send({ - active: false, - action: 'end', - }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/admin/maintenance') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ action: 'end' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should be a no-op if try to exit maintenance mode', async () => { const { status } = await request(app) .post('/admin/maintenance') @@ -132,7 +111,7 @@ describe('/admin/maintenance', () => { it('should fail without cookie or token in body', async () => { const { status, body } = await request(app).post('/admin/maintenance/login').send({}); expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token')); + expect(body).toEqual({ message: 'Missing JWT Token' }); }); it('should succeed with cookie', async () => { diff --git a/e2e/src/specs/server/api/album.e2e-spec.ts b/e2e/src/specs/server/api/album.e2e-spec.ts index f252e5d321..2969d606fe 100644 --- a/e2e/src/specs/server/api/album.e2e-spec.ts +++ b/e2e/src/specs/server/api/album.e2e-spec.ts @@ -613,16 +613,6 @@ describe('/albums', () => { }); describe('DELETE /albums/:id/assets', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/albums/${user1Albums[1].id}/assets`) - .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should be able to remove foreign asset from owned album', async () => { const { status, body } = await request(app) .delete(`/albums/${user2Albums[0].id}/assets`) diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 2339889afe..8ffcf1f525 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -24,13 +24,6 @@ describe('/api-keys', () => { }); describe('POST /api-keys', () => { - it('should not work without permission', async () => { - const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]); - const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('apiKey.create')); - }); - it('should work with apiKey.create', async () => { const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]); const { status, body } = await request(app) @@ -113,15 +106,6 @@ describe('/api-keys', () => { }); describe('GET /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should get api key details', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) @@ -139,16 +123,6 @@ describe('/api-keys', () => { }); describe('PUT /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) - .send({ name: 'new name', permissions: [Permission.All] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should update api key details', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) @@ -169,26 +143,7 @@ describe('/api-keys', () => { }); }); - describe('POST /api-keys/:id/rotate', () => { - it('should not work without permission', async () => { - const { apiKey } = await create(user.accessToken, [Permission.ApiKeyUpdate]); - const { secret } = await create(user.accessToken, [Permission.ApiKeyUpdate]); - const { status, body } = await request(app).post(`/api-keys/${apiKey.id}/rotate`).set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('apiKey.rotate')); - }); - }); - describe('DELETE /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .delete(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should delete an api key', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..f8e49a5813 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -151,14 +151,6 @@ describe('/asset', () => { }); describe('GET /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the asset info', async () => { const { status, body } = await request(app) .get(`/assets/${user1Assets[0].id}`) @@ -306,15 +298,6 @@ describe('/asset', () => { }); describe('PUT /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should favorite an asset', async () => { const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id); expect(before.isFavorite).toBe(false); diff --git a/e2e/src/specs/server/api/jobs.e2e-spec.ts b/e2e/src/specs/server/api/jobs.e2e-spec.ts index be7984404b..f9d8b75c46 100644 --- a/e2e/src/specs/server/api/jobs.e2e-spec.ts +++ b/e2e/src/specs/server/api/jobs.e2e-spec.ts @@ -2,9 +2,7 @@ import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich import { cpSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; -import request from 'supertest'; +import { asBearerAuth, testAssetDir, utils } from 'src/utils'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; describe('/jobs', () => { @@ -50,12 +48,6 @@ describe('/jobs', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); }); - it('should require authentication', async () => { - const { status, body } = await request(app).put('/jobs/metadataExtraction'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should queue metadata extraction for missing assets', async () => { const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c177fa740b..c7c504b3c5 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -55,12 +54,6 @@ describe('/map', () => { }); describe('GET /map/markers', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/markers'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(app) .get('/map/markers') @@ -139,52 +132,6 @@ describe('/map', () => { }); describe('GET /map/reverse-geocode', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/reverse-geocode'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should throw an error if a lat is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lon=123') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is not a number', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=abc&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is out of range', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=91&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), - ); - }); - - it('should throw an error if a lon is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=75') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - const reverseGeocodeTestCases = [ { name: 'Vaucluse', diff --git a/e2e/src/specs/server/api/memory.e2e-spec.ts b/e2e/src/specs/server/api/memory.e2e-spec.ts index e5e2351738..38c6ba875a 100644 --- a/e2e/src/specs/server/api/memory.e2e-spec.ts +++ b/e2e/src/specs/server/api/memory.e2e-spec.ts @@ -7,7 +7,6 @@ import { getMemory, } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -42,14 +41,6 @@ describe('/memories', () => { }); describe('GET /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the memory', async () => { const { status, body } = await request(app) .get(`/memories/${userMemory.id}`) @@ -60,15 +51,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}`) - .send({ isSaved: true }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should update the memory', async () => { const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) }); expect(before.isSaved).toBe(false); @@ -86,15 +68,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should require asset access', async () => { const { status, body } = await request(app) .put(`/memories/${userMemory.id}/assets`) @@ -121,15 +94,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should only remove assets in the memory', async () => { const { status, body } = await request(app) .delete(`/memories/${userMemory.id}/assets`) @@ -156,14 +120,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should delete the memory', async () => { const { status } = await request(app) .delete(`/memories/${userMemory.id}`) diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index ade6475519..3b85e9e4c8 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -102,16 +102,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a redirect uri is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/authorize').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it('should return a redirect uri', async () => { const { status, body } = await request(app) .post('/oauth/authorize') @@ -165,22 +155,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a url is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]), - ); - }); - - it(`should throw an error if the url is empty`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - it(`should throw an error if the state is not provided`, async () => { const { url } = await loginWithOAuth('oauth-auto-register'); const { status, body } = await request(app).post('/oauth/callback').send({ url }); @@ -380,16 +354,6 @@ describe(`/oauth`, () => { }); describe(`POST /oauth/backchannel-logout`, () => { - it(`should throw an error if the logout_token is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it(`should throw an error if an invalid logout token is provided`, async () => { const { status, body } = await request(app) .post('/oauth/backchannel-logout') diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index f0eed82f7a..c6ab9a6704 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -137,14 +136,6 @@ describe('/server', () => { }); describe('GET /server/statistics', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/statistics') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should return the server stats', async () => { const { status, body } = await request(app) .get('/server/statistics') @@ -195,14 +186,6 @@ describe('/server', () => { }); describe('GET /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should return the server license', async () => { await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense); const { status, body } = await request(app) @@ -217,14 +200,6 @@ describe('/server', () => { }); describe('DELETE /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .delete('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete the server license', async () => { await request(app) .delete('/server/license') @@ -236,14 +211,6 @@ describe('/server', () => { }); describe('PUT /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .put('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set the server license', async () => { const { status, body } = await request(app) .put('/server/license') diff --git a/e2e/src/specs/server/api/session.e2e-spec.ts b/e2e/src/specs/server/api/session.e2e-spec.ts index 1766b732cd..6f1599c65c 100644 --- a/e2e/src/specs/server/api/session.e2e-spec.ts +++ b/e2e/src/specs/server/api/session.e2e-spec.ts @@ -1,6 +1,6 @@ import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk'; import { loginDto, signupDto, uuidDto } from 'src/fixtures'; -import { deviceDto, errorDto } from 'src/responses'; +import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -18,7 +18,18 @@ describe('/sessions', () => { it('should get a list of authorized devices', async () => { const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([deviceDto.current]); + expect(body).toEqual([ + { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + current: true, + isPendingSyncReset: false, + deviceOS: '', + deviceType: '', + appVersion: null, + }, + ]); }); }); @@ -56,7 +67,7 @@ describe('/sessions', () => { const response = await request(app) .post('/auth/validateToken') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(response.body).toEqual(errorDto.invalidToken); + expect(response.body).toEqual({ message: 'Invalid user token' }); expect(response.status).toBe(401); }); }); diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index af189f49ac..4e5988a167 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -212,21 +212,21 @@ describe('/shared-links', () => { .query({ key: linkWithAlbum.key + 'foo' }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized if target has been soft deleted', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized for password protected link', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.passwordRequired); + expect(body).toEqual({ message: 'Password required' }); }); it('should get data for correct password protected link', async () => { @@ -306,16 +306,6 @@ describe('/shared-links', () => { }); describe('POST /shared-links', () => { - it('should require an asset/album id', async () => { - const { status, body } = await request(app) - .post('/shared-links') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: SharedLinkType.Album }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }])); - }); - it('should require a valid asset id', async () => { const { status, body } = await request(app) .post('/shared-links') @@ -403,16 +393,6 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); - it('should reject guests removing assets from an individual shared link', async () => { - const { status, body } = await request(app) - .delete(`/shared-links/${linkWithAssets.id}/assets`) - .query({ key: linkWithAssets.key }) - .send({ assetIds: [asset1.id] }); - - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 3b8f642334..21284dcf78 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -1,6 +1,5 @@ -import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { LoginResponseDto, searchStacks } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -8,34 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/stacks', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user2: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [user1, user2] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - - asset = await utils.createAsset(user1.accessToken); + user1 = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('POST /stacks', () => { - it('should require access', async () => { - const user2Asset = await utils.createAsset(user2.accessToken); - const { status, body } = await request(app) - .post('/stacks') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [asset.id, user2Asset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should create a stack', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), diff --git a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts index 114ed2f995..18589ed1d7 100644 --- a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts +++ b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts @@ -1,30 +1,17 @@ import { LoginResponseDto, getServerConfig } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; describe('/server-info', () => { let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('POST /system-metadata/admin-onboarding', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/system-metadata/admin-onboarding') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ isOnboarded: true }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set admin onboarding', async () => { const config = await getServerConfig({}); expect(config.isOnboarded).toBe(false); @@ -41,14 +28,6 @@ describe('/server-info', () => { }); describe('GET /system-metadata/reverse-geocoding-state', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/system-metadata/reverse-geocoding-state') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should get the reverse geocoding state', async () => { const { status, body } = await request(app) .get('/system-metadata/reverse-geocoding-state') diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index 98d66f6350..40c8cd5114 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -9,7 +9,7 @@ import { tagAssets, upsertTags, } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; +import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; @@ -41,13 +41,6 @@ describe('/tags', () => { }); describe('POST /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should work with tag.create', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); @@ -111,13 +104,6 @@ describe('/tags', () => { }); describe('GET /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).get('/tags').set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should start off empty', async () => { const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([]); @@ -157,13 +143,6 @@ describe('/tags', () => { }); describe('PUT /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should upsert tags', async () => { const { status, body } = await request(app) .put(`/tags`) @@ -195,16 +174,6 @@ describe('/tags', () => { }); describe('PUT /tags/assets', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put('/tags/assets') - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should skip assets that are not owned by the user', async () => { const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ create(user.accessToken, { name: 'TagA' }), @@ -255,25 +224,6 @@ describe('/tags', () => { }); describe('GET /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .get(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .get(`/tags/${uuidDto.notFound}`) - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should get tag details', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -311,27 +261,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(admin.accessToken, { name: 'tagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .send({ color: '#000000' }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .set('x-api-key', secret) - .send({ color: '#000000' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.update')); - }); - it('should update a tag', async () => { const tag = await create(user.accessToken, { name: 'tagA' }); const { status, body } = await request(app) @@ -354,23 +283,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.delete')); - }); - it('should delete a tag', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status } = await request(app) @@ -404,27 +316,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id/assets', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to tag own asset', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -463,32 +354,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id/assets', () => { - it('should require authorization', async () => { - const tagA = await create(user.accessToken, { name: 'TagA' }); - await tagAssets( - { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, - { headers: asBearerAuth(user.accessToken) }, - ); - const { status, body } = await request(app) - .delete(`/tags/${tagA.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to remove own asset from own tag', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); await tagAssets( diff --git a/e2e/src/specs/server/api/trash.e2e-spec.ts b/e2e/src/specs/server/api/trash.e2e-spec.ts index 7a1a61f946..e3ea72fdd2 100644 --- a/e2e/src/specs/server/api/trash.e2e-spec.ts +++ b/e2e/src/specs/server/api/trash.e2e-spec.ts @@ -1,7 +1,6 @@ import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -21,13 +20,6 @@ describe('/trash', () => { }); describe('POST /trash/empty', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/empty'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should empty the trash', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -142,13 +134,6 @@ describe('/trash', () => { }); describe('POST /trash/restore', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore all trashed assets', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -198,13 +183,6 @@ describe('/trash', () => { }); describe('POST /trash/restore/assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore/assets'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore a trashed asset by id', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index 160c929591..37f3f2ea99 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto, - Permission, QueueName, createStack, deleteUserAdmin, @@ -10,8 +9,7 @@ import { login, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; +import { createUserDto } from 'src/fixtures'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -46,14 +44,6 @@ describe('/admin/users', () => { }); describe('GET /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .get(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should hide deleted users by default', async () => { const { status, body } = await request(app) .get(`/admin/users`) @@ -88,15 +78,6 @@ describe('/admin/users', () => { }); describe('POST /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send(createUserDto.user1); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should accept `isAdmin`', async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -117,14 +98,6 @@ describe('/admin/users', () => { }); describe('PUT /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .put(`/admin/users/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should allow a non-admin to become an admin', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2')); const { status, body } = await request(app) @@ -225,14 +198,6 @@ describe('/admin/users', () => { }); describe('DELETE /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/admin/users/${userToDelete.userId}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete user', async () => { const { status, body } = await request(app) .delete(`/admin/users/${userToDelete.userId}`) @@ -296,34 +261,7 @@ describe('/admin/users', () => { }); }); - describe('GET /admin/users/:id/calendar-heatmap', () => { - it('should require admin permissions', async () => { - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should require the AdminUserRead permission', async () => { - const { secret } = await utils.createApiKey(admin.accessToken, [Permission.UserRead]); - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('POST /admin/users/:id/restore', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users/${userToDelete.userId}/restore`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should restore a user', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore')); diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 0e62672f36..ce6236eabc 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -1,6 +1,5 @@ -import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -15,19 +14,6 @@ describe('/users', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2); }); - describe('GET /users/me', () => { - it('should not work for shared links', async () => { - const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' }); - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Album, - albumId: album.id, - }); - const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('PUT /users/me', () => { /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { diff --git a/server/src/controllers/index.spec.ts b/server/src/controllers/index.spec.ts index 3d39a4dd3d..69a3f1b0c8 100644 --- a/server/src/controllers/index.spec.ts +++ b/server/src/controllers/index.spec.ts @@ -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) diff --git a/server/src/controllers/job.controller.spec.ts b/server/src/controllers/job.controller.spec.ts new file mode 100644 index 0000000000..4f6f9337c8 --- /dev/null +++ b/server/src/controllers/job.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/server/src/controllers/library.controller.spec.ts b/server/src/controllers/library.controller.spec.ts index c6f1b21ccc..2b606f9835 100644 --- a/server/src/controllers/library.controller.spec.ts +++ b/server/src/controllers/library.controller.spec.ts @@ -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({}); diff --git a/server/src/controllers/map.controller.spec.ts b/server/src/controllers/map.controller.spec.ts new file mode 100644 index 0000000000..4c5f16bc77 --- /dev/null +++ b/server/src/controllers/map.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/server/src/controllers/oauth.controller.spec.ts b/server/src/controllers/oauth.controller.spec.ts new file mode 100644 index 0000000000..a8aa33a4f7 --- /dev/null +++ b/server/src/controllers/oauth.controller.spec.ts @@ -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' }, + ]), + ); + }); + }); +}); diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 731baa673f..856a74e340 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -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') diff --git a/server/src/controllers/system-metadata.controller.spec.ts b/server/src/controllers/system-metadata.controller.spec.ts new file mode 100644 index 0000000000..0f5fba08a9 --- /dev/null +++ b/server/src/controllers/system-metadata.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/server/src/controllers/trash.controller.spec.ts b/server/src/controllers/trash.controller.spec.ts new file mode 100644 index 0000000000..1b923692b1 --- /dev/null +++ b/server/src/controllers/trash.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts index bc73007593..9670f4c5ca 100644 --- a/server/test/medium/specs/services/album.service.spec.ts +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -1,10 +1,13 @@ import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { AlbumService } from 'src/services/album.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -12,7 +15,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(AlbumService, { database: db || defaultDatabase, - real: [AlbumRepository, UserRepository], + real: [AccessRepository, AlbumRepository, AssetRepository, UserRepository], mock: [LoggingRepository], }); }; @@ -22,16 +25,35 @@ beforeAll(async () => { }); describe(AlbumService.name, () => { + describe('removeAssets', () => { + it('should not remove assets from an album of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]); + + await expect(sut.removeAssets(factory.auth({ user: otherUser }), album.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no albumAsset.delete access', + ); + await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('database triggers', () => { it('should cascade delete an album when the owner is deleted', async () => { const { ctx } = setup(); const { user } = await ctx.newUser(); - await ctx.newAlbum({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); await ctx.get(UserRepository).delete({ id: user.id }, true); - await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); - await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album').selectAll().where('id', '=', album.id).execute()).resolves.toEqual( + [], + ); + await expect( + ctx.database.selectFrom('album_user').selectAll().where('albumId', '=', album.id).execute(), + ).resolves.toEqual([]); }); }); }); diff --git a/server/test/medium/specs/services/api-key.service.spec.ts b/server/test/medium/specs/services/api-key.service.spec.ts index 1a5842dab4..32fbf7b9d9 100644 --- a/server/test/medium/specs/services/api-key.service.spec.ts +++ b/server/test/medium/specs/services/api-key.service.spec.ts @@ -24,6 +24,44 @@ beforeAll(async () => { }); describe(ApiKeyService.name, () => { + describe('getById', () => { + it('should not return an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.getById(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + }); + }); + + describe('update', () => { + it('should not update an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.update(factory.auth({ user: otherUser }), apiKey.id, { name: 'new name' })).rejects.toThrow( + 'API Key not found', + ); + }); + }); + + describe('delete', () => { + it('should not delete an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.delete(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + await expect(sut.getById(factory.auth({ user }), apiKey.id)).resolves.toEqual( + expect.objectContaining({ id: apiKey.id }), + ); + }); + }); + describe('rotate', () => { it('should not rotate an api key of another user', async () => { const { sut, ctx } = setup(); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 2569b29353..cb6e1d744d 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -45,6 +45,19 @@ beforeAll(async () => { }); describe(AssetService.name, () => { + describe('get', () => { + it('should not return an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.get(factory.auth({ user: otherUser }), asset.id)).rejects.toThrow( + 'Not found or no asset.read access', + ); + }); + }); + describe('getStatistics', () => { it('should return stats as numbers, not strings', async () => { const { sut, ctx } = setup(); @@ -334,6 +347,17 @@ describe(AssetService.name, () => { }); describe('update', () => { + it('should not update an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.update(factory.auth({ user: otherUser }), asset.id, {})).rejects.toThrow( + 'Not found or no asset.update access', + ); + }); + it('should automatically lock lockable columns', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queue.mockResolvedValue(); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index b3a3da6010..5a9217ea60 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -34,7 +34,70 @@ const setup = (db?: Kysely) => { }); }; +/** A memory owned by one user, plus another user's auth to attempt access with */ +const newMemoryOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + return { memory, asset, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + describe(MemoryService.name, () => { + describe('get', () => { + it('should not return a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.read access'); + }); + }); + + describe('update', () => { + it('should not update a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, memory.id, { isSaved: true })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, auth, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.delete access'); + await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id })); + }); + }); + + describe('addAssets', () => { + it('should not add assets to a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.addAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.read access', + ); + }); + }); + + describe('removeAssets', () => { + it('should not remove assets from a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await expect(sut.removeAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + beforeEach(async () => { defaultDatabase = await getKyselyDB(); }); diff --git a/server/test/medium/specs/services/stack.service.spec.ts b/server/test/medium/specs/services/stack.service.spec.ts new file mode 100644 index 0000000000..68c06465b7 --- /dev/null +++ b/server/test/medium/specs/services/stack.service.spec.ts @@ -0,0 +1,44 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { DB } from 'src/schema'; +import { StackService } from 'src/services/stack.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(StackService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, StackRepository], + mock: [EventRepository, LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(StackService.name, () => { + describe('create', () => { + it('should not stack an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: otherAsset } = await ctx.newAsset({ ownerId: otherUser.id }); + + await expect(sut.create(factory.auth({ user }), { assetIds: [asset.id, otherAsset.id] })).rejects.toThrow( + 'Not found or no asset.update access', + ); + await expect( + ctx.database.selectFrom('stack').selectAll().where('ownerId', '=', user.id).execute(), + ).resolves.toEqual([]); + }); + }); +}); diff --git a/server/test/medium/specs/services/tag.service.spec.ts b/server/test/medium/specs/services/tag.service.spec.ts index 989e4f535f..52d002eed2 100644 --- a/server/test/medium/specs/services/tag.service.spec.ts +++ b/server/test/medium/specs/services/tag.service.spec.ts @@ -22,12 +22,61 @@ const setup = (db?: Kysely) => { }); }; +/** A tag owned by one user, plus another user's auth to attempt access with */ +const newTagOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] }); + + return { tag, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(TagService.name, () => { + describe('get', () => { + it('should not return a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.read access'); + }); + }); + + describe('update', () => { + it('should not update a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, tag.id, { color: '#000000' })).rejects.toThrow( + 'Not found or no tag.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.delete access'); + await expect(sut.get(auth, tag.id)).resolves.toEqual(expect.objectContaining({ id: tag.id })); + }); + }); + describe('addAssets', () => { + it('should not add assets to a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: otherAuth.user.id }); + + await expect(sut.addAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + }); + it('should lock exif column', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); @@ -53,6 +102,22 @@ describe(TagService.name, () => { await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); }); }); + + describe('removeAssets', () => { + it('should not remove assets from a tag of another user', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + await sut.addAssets(auth, tag.id, { ids: [asset.id] }); + + await expect(sut.removeAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('deleteEmptyTags', () => { it('single tag exists, not connected to any assets, and is deleted', async () => { const { sut, ctx } = setup();