diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts new file mode 100644 index 0000000000..f1d7bd1123 --- /dev/null +++ b/e2e/src/api/specs/download.e2e-spec.ts @@ -0,0 +1,62 @@ +import { AssetResponseDto, LoginResponseDto } from '@immich/sdk'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, dbUtils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/download', () => { + let admin: LoginResponseDto; + let asset1: AssetResponseDto; + + beforeAll(async () => { + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup(); + asset1 = await apiUtils.createAsset(admin.accessToken); + }); + + describe('POST /download/info', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post(`/download/info`) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download info', async () => { + const { status, body } = await request(app) + .post('/download/info') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + archives: [expect.objectContaining({ assetIds: [asset1.id] })], + }) + ); + }); + }); + + describe('POST /download/asset/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post( + `/download/asset/${asset1.id}` + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download file', async () => { + const response = await request(app) + .post(`/download/asset/${asset1.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toEqual('image/jpeg'); + }); + }); +}); diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts new file mode 100644 index 0000000000..b09b6e5212 --- /dev/null +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -0,0 +1,30 @@ +import { errorDto } from 'src/responses'; +import { apiUtils, app, dbUtils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +describe(`/oauth`, () => { + beforeAll(() => { + apiUtils.setup(); + }); + + beforeEach(async () => { + await dbUtils.reset(); + await apiUtils.adminSetup(); + }); + + describe('POST /oauth/authorize', () => { + 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.badRequest([ + 'redirectUri must be a string', + 'redirectUri should not be empty', + ]) + ); + }); + }); +}); diff --git a/server/e2e/api/specs/partner.e2e-spec.ts b/e2e/src/api/specs/partner.e2e-spec.ts similarity index 51% rename from server/e2e/api/specs/partner.e2e-spec.ts rename to e2e/src/api/specs/partner.e2e-spec.ts index b254aa61f2..5b441b767a 100644 --- a/server/e2e/api/specs/partner.e2e-spec.ts +++ b/e2e/src/api/specs/partner.e2e-spec.ts @@ -1,68 +1,63 @@ -import { LoginResponseDto, PartnerDirection } from '@app/domain'; -import { PartnerController } from '@app/immich'; -import { errorStub, userDto } from '@test/fixtures'; +import { LoginResponseDto, createPartner } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; +import { beforeAll, describe, expect, it } from 'vitest'; -describe(`${PartnerController.name} (e2e)`, () => { - let server: any; +describe('/partner', () => { + let admin: LoginResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; let user3: LoginResponseDto; beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); + apiUtils.setup(); + await dbUtils.reset(); - await testApp.reset(); - await api.authApi.adminSignUp(server); - const admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), - api.userApi.create(server, admin.accessToken, userDto.user3), - ]); + admin = await apiUtils.adminSetup(); [user1, user2, user3] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), - api.authApi.login(server, userDto.user3), + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), ]); await Promise.all([ - api.partnerApi.create(server, user1.accessToken, user2.userId), - api.partnerApi.create(server, user2.accessToken, user1.userId), + createPartner( + { id: user2.userId }, + { headers: asBearerAuth(user1.accessToken) } + ), + createPartner( + { id: user1.userId }, + { headers: asBearerAuth(user2.accessToken) } + ), ]); }); - afterAll(async () => { - await testApp.teardown(); - }); - describe('GET /partner', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/partner'); + const { status, body } = await request(app).get('/partner'); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should get all partners shared by user', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/partner') .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ direction: PartnerDirection.SharedBy }); + .query({ direction: 'shared-by' }); expect(status).toBe(200); expect(body).toEqual([expect.objectContaining({ id: user2.userId })]); }); it('should get all partners that share with user', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/partner') .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ direction: PartnerDirection.SharedWith }); + .query({ direction: 'shared-with' }); expect(status).toBe(200); expect(body).toEqual([expect.objectContaining({ id: user2.userId })]); @@ -71,14 +66,16 @@ describe(`${PartnerController.name} (e2e)`, () => { describe('POST /partner/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(server).post(`/partner/${user3.userId}`); + const { status, body } = await request(app).post( + `/partner/${user3.userId}` + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should share with new partner', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .post(`/partner/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); @@ -87,44 +84,52 @@ describe(`${PartnerController.name} (e2e)`, () => { }); it('should not share with new partner if already sharing with this partner', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .post(`/partner/${user2.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' })); + expect(body).toEqual( + expect.objectContaining({ message: 'Partner already exists' }) + ); }); }); describe('PUT /partner/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(server).put(`/partner/${user2.userId}`); + const { status, body } = await request(app).put( + `/partner/${user2.userId}` + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should update partner', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/partner/${user2.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ inTimeline: false }); expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false })); + expect(body).toEqual( + expect.objectContaining({ id: user2.userId, inTimeline: false }) + ); }); }); describe('DELETE /partner/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(server).delete(`/partner/${user3.userId}`); + const { status, body } = await request(app).delete( + `/partner/${user3.userId}` + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should delete partner', async () => { - const { status } = await request(server) + const { status } = await request(app) .delete(`/partner/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); @@ -132,12 +137,14 @@ describe(`${PartnerController.name} (e2e)`, () => { }); it('should throw a bad request if partner not found', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/partner/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' })); + expect(body).toEqual( + expect.objectContaining({ message: 'Partner not found' }) + ); }); }); }); diff --git a/server/e2e/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts similarity index 68% rename from server/e2e/api/specs/server-info.e2e-spec.ts rename to e2e/src/api/specs/server-info.e2e-spec.ts index f5664a11a3..d5092ad4f0 100644 --- a/server/e2e/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -1,38 +1,30 @@ -import { LoginResponseDto } from '@app/domain'; -import { ServerInfoController } from '@app/immich'; -import { errorStub, userDto } from '@test/fixtures'; +import { LoginResponseDto, getServerConfig } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, dbUtils } from 'src/utils'; import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; +import { beforeAll, describe, expect, it } from 'vitest'; -describe(`${ServerInfoController.name} (e2e)`, () => { - let server: any; +describe('/server-info', () => { let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - await api.userApi.create(server, admin.accessToken, userDto.user1); - nonAdmin = await api.authApi.login(server, userDto.user1); - }); - - afterAll(async () => { - await testApp.teardown(); + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup({ onboarding: false }); + nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1); }); describe('GET /server-info', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/server-info'); + const { status, body } = await request(app).get('/server-info'); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should return the disk information', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/server-info') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -50,7 +42,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/ping', () => { it('should respond with pong', async () => { - const { status, body } = await request(server).get('/server-info/ping'); + const { status, body } = await request(app).get('/server-info/ping'); expect(status).toBe(200); expect(body).toEqual({ res: 'pong' }); }); @@ -58,7 +50,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/version', () => { it('should respond with the server version', async () => { - const { status, body } = await request(server).get('/server-info/version'); + const { status, body } = await request(app).get('/server-info/version'); expect(status).toBe(200); expect(body).toEqual({ major: expect.any(Number), @@ -70,12 +62,12 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/features', () => { it('should respond with the server features', async () => { - const { status, body } = await request(server).get('/server-info/features'); + const { status, body } = await request(app).get('/server-info/features'); expect(status).toBe(200); expect(body).toEqual({ - smartSearch: true, + smartSearch: false, configFile: false, - facialRecognition: true, + facialRecognition: false, map: true, reverseGeocoding: true, oauth: false, @@ -90,7 +82,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/config', () => { it('should respond with the server configuration', async () => { - const { status, body } = await request(server).get('/server-info/config'); + const { status, body } = await request(app).get('/server-info/config'); expect(status).toBe(200); expect(body).toEqual({ loginPageMessage: '', @@ -105,21 +97,23 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/server-info/statistics'); + const { status, body } = await request(app).get( + '/server-info/statistics' + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should only work for admins', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/server-info/statistics') .set('Authorization', `Bearer ${nonAdmin.accessToken}`); expect(status).toBe(403); - expect(body).toEqual(errorStub.forbidden); + expect(body).toEqual(errorDto.forbidden); }); it('should return the server stats', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/server-info/statistics') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -151,7 +145,9 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/media-types', () => { it('should return accepted media types', async () => { - const { status, body } = await request(server).get('/server-info/media-types'); + const { status, body } = await request(app).get( + '/server-info/media-types' + ); expect(status).toBe(200); expect(body).toEqual({ sidecar: ['.xmp'], @@ -163,7 +159,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('GET /server-info/theme', () => { it('should respond with the server theme', async () => { - const { status, body } = await request(server).get('/server-info/theme'); + const { status, body } = await request(app).get('/server-info/theme'); expect(status).toBe(200); expect(body).toEqual({ customCss: '', @@ -173,15 +169,15 @@ describe(`${ServerInfoController.name} (e2e)`, () => { describe('POST /server-info/admin-onboarding', () => { it('should set admin onboarding', async () => { - const config = await api.serverInfoApi.getConfig(server); + const config = await getServerConfig({}); expect(config.isOnboarded).toBe(false); - const { status } = await request(server) + const { status } = await request(app) .post('/server-info/admin-onboarding') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - const newConfig = await api.serverInfoApi.getConfig(server); + const newConfig = await getServerConfig({}); expect(newConfig.isOnboarded).toBe(true); }); }); diff --git a/server/e2e/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts similarity index 58% rename from server/e2e/api/specs/system-config.e2e-spec.ts rename to e2e/src/api/specs/system-config.e2e-spec.ts index a6b7387091..8d293b3d24 100644 --- a/server/e2e/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -1,49 +1,47 @@ -import { LoginResponseDto } from '@app/domain'; -import { SystemConfigController } from '@app/immich'; -import { errorStub, userDto } from '@test/fixtures'; +import { LoginResponseDto } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, dbUtils } from 'src/utils'; import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; +import { beforeAll, describe, expect, it } from 'vitest'; -describe(`${SystemConfigController.name} (e2e)`, () => { - let server: any; +describe('/system-config', () => { let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - await api.userApi.create(server, admin.accessToken, userDto.user1); - nonAdmin = await api.authApi.login(server, userDto.user1); - }); - - afterAll(async () => { - await testApp.teardown(); + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup(); + nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1); }); describe('GET /system-config/map/style.json', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/system-config/map/style.json'); + const { status, body } = await request(app).get( + '/system-config/map/style.json' + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should throw an error if a theme is not light or dark', async () => { for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/system-config/map/style.json') .query({ theme }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['theme must be one of the following values: light, dark'])); + expect(body).toEqual( + errorDto.badRequest([ + 'theme must be one of the following values: light, dark', + ]) + ); } }); it('should return the light style.json', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/system-config/map/style.json') .query({ theme: 'light' }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -52,7 +50,7 @@ describe(`${SystemConfigController.name} (e2e)`, () => { }); it('should return the dark style.json', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/system-config/map/style.json') .query({ theme: 'dark' }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -61,7 +59,7 @@ describe(`${SystemConfigController.name} (e2e)`, () => { }); it('should not require admin authentication', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/system-config/map/style.json') .query({ theme: 'dark' }) .set('Authorization', `Bearer ${nonAdmin.accessToken}`); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts new file mode 100644 index 0000000000..74e1646802 --- /dev/null +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -0,0 +1,323 @@ +import { + LoginResponseDto, + UserResponseDto, + createUser, + deleteUser, + getUserById, +} from '@immich/sdk'; +import { createUserDto, userDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +describe('/server-info', () => { + let admin: LoginResponseDto; + + beforeAll(async () => { + apiUtils.setup(); + }); + + beforeEach(async () => { + await dbUtils.reset(); + admin = await apiUtils.adminSetup({ onboarding: false }); + }); + + describe('GET /user', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/user'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should start with the admin', async () => { + const { status, body } = await request(app) + .get('/user') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + }); + + it('should hide deleted users', async () => { + const user1 = await apiUtils.userSetup( + admin.accessToken, + createUserDto.user1 + ); + await deleteUser( + { id: user1.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); + + const { status, body } = await request(app) + .get(`/user`) + .query({ isAll: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + }); + + it('should include deleted users', async () => { + const user1 = await apiUtils.userSetup( + admin.accessToken, + createUserDto.user1 + ); + await deleteUser( + { id: user1.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); + + const { status, body } = await request(app) + .get(`/user`) + .query({ isAll: false }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body[0]).toMatchObject({ + id: user1.userId, + email: 'user1@immich.cloud', + deletedAt: expect.any(String), + }); + expect(body[1]).toMatchObject({ + id: admin.userId, + email: 'admin@immich.cloud', + }); + }); + }); + + describe('GET /user/info/:id', () => { + it('should require authentication', async () => { + const { status } = await request(app).get(`/user/info/${admin.userId}`); + expect(status).toEqual(401); + }); + + it('should get the user info', async () => { + const { status, body } = await request(app) + .get(`/user/info/${admin.userId}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: admin.userId, + email: 'admin@immich.cloud', + }); + }); + }); + + describe('GET /user/me', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/user/me`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get my info', async () => { + const { status, body } = await request(app) + .get(`/user/me`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: admin.userId, + email: 'admin@immich.cloud', + }); + }); + }); + + describe('POST /user', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post(`/user`) + .send(createUserDto.user1); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + for (const key of Object.keys(createUserDto.user1)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(app) + .post(`/user`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ...createUserDto.user1, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + + it('should ignore `isAdmin`', async () => { + const { status, body } = await request(app) + .post(`/user`) + .send({ + isAdmin: true, + email: 'user1@immich.cloud', + password: 'Password123', + name: 'Immich', + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toMatchObject({ + email: 'user1@immich.cloud', + isAdmin: false, + shouldChangePassword: true, + }); + expect(status).toBe(201); + }); + + it('should create a user without memories enabled', async () => { + const { status, body } = await request(app) + .post(`/user`) + .send({ + email: 'no-memories@immich.cloud', + password: 'Password123', + name: 'No Memories', + memoriesEnabled: false, + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toMatchObject({ + email: 'no-memories@immich.cloud', + memoriesEnabled: false, + }); + expect(status).toBe(201); + }); + }); + + describe('DELETE /user/:id', () => { + let userToDelete: UserResponseDto; + + beforeEach(async () => { + userToDelete = await createUser( + { createUserDto: createUserDto.user1 }, + { headers: asBearerAuth(admin.accessToken) } + ); + }); + + it('should require authentication', async () => { + const { status, body } = await request(app).delete( + `/user/${userToDelete.id}` + ); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should delete user', async () => { + const { status, body } = await request(app) + .delete(`/user/${userToDelete.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...userToDelete, + updatedAt: expect.any(String), + deletedAt: expect.any(String), + }); + }); + }); + + describe('PUT /user', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/user`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + for (const key of Object.keys(userDto.admin)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(app) + .put(`/user`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ...userDto.admin, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + + it('should not allow a non-admin to become an admin', async () => { + const user = await apiUtils.userSetup( + admin.accessToken, + createUserDto.user1 + ); + + const { status, body } = await request(app) + .put(`/user`) + .send({ isAdmin: true, id: user.userId }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.alreadyHasAdmin); + }); + + it('ignores updates to profileImagePath', async () => { + const { status, body } = await request(app) + .put(`/user`) + .send({ id: admin.userId, profileImagePath: 'invalid.jpg' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' }); + }); + + it('should ignore updates to createdAt, updatedAt and deletedAt', async () => { + const before = await getUserById( + { id: admin.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); + + const { status, body } = await request(app) + .put(`/user`) + .send({ + id: admin.userId, + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', + deletedAt: '2023-01-01T00:00:00.000Z', + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toStrictEqual(before); + }); + + it('should update first and last name', async () => { + const before = await getUserById( + { id: admin.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); + + const { status, body } = await request(app) + .put(`/user`) + .send({ + id: admin.userId, + name: 'Name', + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...before, + updatedAt: expect.any(String), + name: 'Name', + }); + expect(before.updatedAt).not.toEqual(body.updatedAt); + }); + + it('should update memories enabled', async () => { + const before = await getUserById( + { id: admin.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); + const { status, body } = await request(app) + .put(`/user`) + .send({ + id: admin.userId, + memoriesEnabled: false, + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + ...before, + updatedAt: expect.anything(), + memoriesEnabled: false, + }); + expect(before.updatedAt).not.toEqual(body.updatedAt); + }); + }); +}); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index fb667160a8..309ba6b939 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,3 +1,5 @@ +import { UserAvatarColor } from '@immich/sdk'; + export const uuidDto = { invalid: 'invalid-uuid', // valid uuid v4 @@ -17,3 +19,62 @@ export const loginDto = { export const signupDto = { admin: adminSignupDto, }; + +export const createUserDto = { + user1: { + email: 'user1@immich.cloud', + name: 'User 1', + password: 'password1', + }, + user2: { + email: 'user2@immich.cloud', + name: 'User 2', + password: 'password12', + }, + user3: { + email: 'user3@immich.cloud', + name: 'User 3', + password: 'password123', + }, +}; + +export const userDto = { + admin: { + name: signupDto.admin.name, + email: signupDto.admin.email, + password: signupDto.admin.password, + storageLabel: 'admin', + externalPath: null, + oauthId: '', + shouldChangePassword: false, + profileImagePath: '', + createdAt: new Date('2021-01-01'), + deletedAt: null, + updatedAt: new Date('2021-01-01'), + tags: [], + assets: [], + memoriesEnabled: true, + avatarColor: UserAvatarColor.Primary, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, + user1: { + name: createUserDto.user1.name, + email: createUserDto.user1.email, + password: createUserDto.user1.password, + storageLabel: null, + externalPath: null, + oauthId: '', + shouldChangePassword: false, + profileImagePath: '', + createdAt: new Date('2021-01-01'), + deletedAt: null, + updatedAt: new Date('2021-01-01'), + tags: [], + assets: [], + memoriesEnabled: true, + avatarColor: UserAvatarColor.Primary, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, +}; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 7480d32b63..ece0c46134 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,6 +1,10 @@ import { + AssetResponseDto, + CreateAssetDto, + CreateUserDto, LoginResponseDto, createApiKey, + createUser, defaults, login, setAdminOnboarding, @@ -8,10 +12,12 @@ import { } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; import { spawn } from 'child_process'; +import { randomBytes } from 'node:crypto'; import { access } from 'node:fs/promises'; import path from 'node:path'; import pg from 'pg'; import { loginDto, signupDto } from 'src/fixtures'; +import request from 'supertest'; export const app = 'http://127.0.0.1:2283/api'; @@ -105,22 +111,60 @@ export const immichCli = async (args: string[]) => { return deferred; }; +export interface AdminSetupOptions { + onboarding?: boolean; +} + export const apiUtils = { setup: () => { setBaseUrl(); }, - adminSetup: async () => { + adminSetup: async (options?: AdminSetupOptions) => { + options = options || { onboarding: true }; + await signUpAdmin({ signUpDto: signupDto.admin }); const response = await login({ loginCredentialDto: loginDto.admin }); - await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) }); + if (options.onboarding) { + await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) }); + } return response; }, + userSetup: async (accessToken: string, dto: CreateUserDto) => { + await createUser( + { createUserDto: dto }, + { headers: asBearerAuth(accessToken) } + ); + return login({ + loginCredentialDto: { email: dto.email, password: dto.password }, + }); + }, createApiKey: (accessToken: string) => { return createApiKey( { apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) } ); }, + createAsset: async ( + accessToken: string, + dto?: Omit + ) => { + dto = dto || { + deviceAssetId: 'test-1', + deviceId: 'test', + fileCreatedAt: new Date().toISOString(), + fileModifiedAt: new Date().toISOString(), + }; + const { body } = await request(app) + .post(`/asset/upload`) + .field('deviceAssetId', dto.deviceAssetId) + .field('deviceId', dto.deviceId) + .field('fileCreatedAt', dto.fileCreatedAt) + .field('fileModifiedAt', dto.fileModifiedAt) + .attach('assetData', randomBytes(32), 'example.jpg') + .set('Authorization', `Bearer ${accessToken}`); + + return body as AssetResponseDto; + }, }; export const cliUtils = { diff --git a/server/e2e/api/specs/download.e2e-spec.ts b/server/e2e/api/specs/download.e2e-spec.ts deleted file mode 100644 index 9f8c477df9..0000000000 --- a/server/e2e/api/specs/download.e2e-spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { AssetResponseDto, IAssetRepository, LibraryResponseDto, LoginResponseDto, mapAsset } from '@app/domain'; -import { AssetController } from '@app/immich'; -import { AssetEntity } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, userDto } from '@test/fixtures'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp } from '../utils'; - -describe(`${AssetController.name} (e2e)`, () => { - let app: INestApplication; - let server: any; - let assetRepository: IAssetRepository; - let user1: LoginResponseDto; - let libraries: LibraryResponseDto[]; - let asset1: AssetResponseDto; - - const createAsset = async ( - loginResponse: LoginResponseDto, - fileCreatedAt: Date, - other: Partial = {}, - ) => { - const asset = await assetRepository.create( - generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }), - ); - - return mapAsset(asset); - }; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - - await testApp.reset(); - - await api.authApi.adminSignUp(server); - const admin = await api.authApi.adminLogin(server); - - await api.userApi.create(server, admin.accessToken, userDto.user1); - user1 = await api.authApi.login(server, userDto.user1); - libraries = await api.libraryApi.getAll(server, user1.accessToken); - asset1 = await createAsset(user1, new Date('1970-01-01')); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - describe('POST /download/info', () => { - it('should require authentication', async () => { - const { status, body } = await request(server) - .post(`/download/info`) - .send({ assetIds: [asset1.id] }); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should download info', async () => { - const { status, body } = await request(server) - .post('/download/info') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [asset1.id] }); - - expect(status).toBe(201); - expect(body).toEqual(expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })] })); - }); - }); - - describe('POST /download/asset/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).post(`/download/asset/${asset1.id}`); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should download file', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example'); - const response = await request(server) - .post(`/download/asset/${asset.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(response.status).toBe(200); - expect(response.headers['content-type']).toEqual('image/jpeg'); - }); - }); -}); diff --git a/server/e2e/api/specs/oauth.e2e-spec.ts b/server/e2e/api/specs/oauth.e2e-spec.ts deleted file mode 100644 index b6136971d0..0000000000 --- a/server/e2e/api/specs/oauth.e2e-spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { OAuthController } from '@app/immich'; -import { errorStub } from '@test/fixtures'; -import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; - -describe(`${OAuthController.name} (e2e)`, () => { - let server: any; - - beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - }); - - describe('POST /oauth/authorize', () => { - it(`should throw an error if a redirect uri is not provided`, async () => { - const { status, body } = await request(server).post('/oauth/authorize').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty'])); - }); - }); -}); diff --git a/server/e2e/api/specs/user.e2e-spec.ts b/server/e2e/api/specs/user.e2e-spec.ts deleted file mode 100644 index cd7c8147a3..0000000000 --- a/server/e2e/api/specs/user.e2e-spec.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain'; -import { AppModule, UserController } from '@app/immich'; -import { UserEntity } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures'; -import request from 'supertest'; -import { Repository } from 'typeorm'; -import { api } from '../../client'; -import { testApp } from '../utils'; - -describe(`${UserController.name}`, () => { - let app: INestApplication; - let server: any; - let loginResponse: LoginResponseDto; - let accessToken: string; - let userService: UserService; - let userRepository: Repository; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity)); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - loginResponse = await api.authApi.adminLogin(server); - accessToken = loginResponse.accessToken; - - userService = app.get(UserService); - }); - - describe('GET /user', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/user'); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should start with the admin', async () => { - const { status, body } = await request(server).get('/user').set('Authorization', `Bearer ${accessToken}`); - expect(status).toEqual(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.app' }); - }); - - it('should hide deleted users', async () => { - const user1 = await api.userApi.create(server, accessToken, { - email: `user1@immich.app`, - password: 'Password123', - name: `User 1`, - }); - - await api.userApi.delete(server, accessToken, user1.id); - - const { status, body } = await request(server) - .get(`/user`) - .query({ isAll: true }) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.app' }); - }); - - it('should include deleted users', async () => { - const user1 = await api.userApi.create(server, accessToken, userDto.user1); - - await api.userApi.delete(server, accessToken, user1.id); - - const { status, body } = await request(server) - .get(`/user`) - .query({ isAll: false }) - .set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body[0]).toMatchObject({ id: user1.id, email: 'user1@immich.app', deletedAt: expect.any(String) }); - expect(body[1]).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' }); - }); - }); - - describe('GET /user/info/:id', () => { - it('should require authentication', async () => { - const { status } = await request(server).get(`/user/info/${loginResponse.userId}`); - expect(status).toEqual(401); - }); - - it('should get the user info', async () => { - const { status, body } = await request(server) - .get(`/user/info/${loginResponse.userId}`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' }); - }); - }); - - describe('GET /user/me', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get(`/user/me`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get my info', async () => { - const { status, body } = await request(server).get(`/user/me`).set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' }); - }); - }); - - describe('POST /user', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).post(`/user`).send(userSignupStub); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - for (const key of Object.keys(userSignupStub)) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(server) - .post(`/user`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ ...userSignupStub, [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - } - - it('should ignore `isAdmin`', async () => { - const { status, body } = await request(server) - .post(`/user`) - .send({ - isAdmin: true, - email: 'user1@immich.app', - password: 'Password123', - name: 'Immich', - }) - .set('Authorization', `Bearer ${accessToken}`); - expect(body).toMatchObject({ - email: 'user1@immich.app', - isAdmin: false, - shouldChangePassword: true, - }); - expect(status).toBe(201); - }); - - it('should create a user without memories enabled', async () => { - const { status, body } = await request(server) - .post(`/user`) - .send({ - email: 'no-memories@immich.app', - password: 'Password123', - name: 'No Memories', - memoriesEnabled: false, - }) - .set('Authorization', `Bearer ${accessToken}`); - expect(body).toMatchObject({ - email: 'no-memories@immich.app', - memoriesEnabled: false, - }); - expect(status).toBe(201); - }); - }); - - describe('DELETE /user/:id', () => { - let userToDelete: UserResponseDto; - - beforeEach(async () => { - userToDelete = await api.userApi.create(server, accessToken, { - email: userStub.user1.email, - name: userStub.user1.name, - password: 'superSecurePassword', - }); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).delete(`/user/${userToDelete.id}`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should delete user', async () => { - const deleteRequest = await request(server) - .delete(`/user/${userToDelete.id}`) - .set('Authorization', `Bearer ${accessToken}`); - - expect(deleteRequest.status).toBe(200); - expect(deleteRequest.body).toEqual({ - ...userToDelete, - updatedAt: expect.any(String), - deletedAt: expect.any(String), - }); - - await userRepository.save({ id: deleteRequest.body.id, deletedAt: new Date('1970-01-01').toISOString() }); - - await userService.handleUserDelete({ id: userToDelete.id }); - - const { status, body } = await request(server) - .get('/user') - .query({ isAll: false }) - .set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(1); - }); - }); - - describe('PUT /user', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).put(`/user`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - for (const key of Object.keys(userStub.admin)) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(server) - .put(`/user`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ ...userStub.admin, [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - } - - it('should not allow a non-admin to become an admin', async () => { - const user = await api.userApi.create(server, accessToken, { - email: 'user1@immich.app', - password: 'Password123', - name: 'Immich User', - }); - - const { status, body } = await request(server) - .put(`/user`) - .send({ isAdmin: true, id: user.id }) - .set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.alreadyHasAdmin); - }); - - it('ignores updates to profileImagePath', async () => { - const user = await api.userApi.update(server, accessToken, { - id: loginResponse.userId, - profileImagePath: 'invalid.jpg', - } as any); - - expect(user).toMatchObject({ id: loginResponse.userId, profileImagePath: '' }); - }); - - it('should ignore updates to createdAt, updatedAt and deletedAt', async () => { - const before = await api.userApi.get(server, accessToken, loginResponse.userId); - const after = await api.userApi.update(server, accessToken, { - id: loginResponse.userId, - createdAt: '2023-01-01T00:00:00.000Z', - updatedAt: '2023-01-01T00:00:00.000Z', - deletedAt: '2023-01-01T00:00:00.000Z', - } as any); - - expect(after).toStrictEqual(before); - }); - - it('should update first and last name', async () => { - const before = await api.userApi.get(server, accessToken, loginResponse.userId); - const after = await api.userApi.update(server, accessToken, { - id: before.id, - name: 'Name', - }); - - expect(after).toEqual({ - ...before, - updatedAt: expect.any(String), - name: 'Name', - }); - expect(before.updatedAt).not.toEqual(after.updatedAt); - }); - - it('should update memories enabled', async () => { - const before = await api.userApi.get(server, accessToken, loginResponse.userId); - const after = await api.userApi.update(server, accessToken, { - id: before.id, - memoriesEnabled: false, - }); - - expect(after).toMatchObject({ - ...before, - updatedAt: expect.anything(), - memoriesEnabled: false, - }); - expect(before.updatedAt).not.toEqual(after.updatedAt); - }); - }); -}); diff --git a/server/src/immich/controllers/partner.controller.ts b/server/src/immich/controllers/partner.controller.ts index 75f716f58d..6370d8e718 100644 --- a/server/src/immich/controllers/partner.controller.ts +++ b/server/src/immich/controllers/partner.controller.ts @@ -15,6 +15,7 @@ export class PartnerController { @Get() @ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true }) + // TODO: remove 'direction' and convert to full query dto getPartners(@Auth() auth: AuthDto, @Query('direction') direction: PartnerDirection): Promise { return this.service.getAll(auth, direction); }