diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 2f270ca7ca..0e62672f36 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -1,34 +1,18 @@ -import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, SharedLinkType, 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'; -const userLicense = { - licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', - activationKey: - 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', -}; - describe('/users', () => { let admin: LoginResponseDto; - let deletedUser: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - - [deletedUser, nonAdmin] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - - await deleteUserAdmin( - { id: deletedUser.userId, userAdminDeleteDto: {} }, - { headers: asBearerAuth(admin.accessToken) }, - ); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2); }); describe('GET /users/me', () => { @@ -42,55 +26,9 @@ describe('/users', () => { expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); }); - - it('should get my user', async () => { - const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - quotaUsageInBytes: 0, - }); - }); - - it('should get my user with license info', async () => { - const { status: licenseStatus } = await request(app) - .put(`/users/me/license`) - .send(userLicense) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(licenseStatus).toBe(200); - const { status, body } = await request(app) - .get(`/users/me`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: nonAdmin.userId, - email: nonAdmin.userEmail, - quotaUsageInBytes: 0, - license: userLicense, - }); - }); }); describe('PUT /users/me', () => { - it('should update first and last name', async () => { - const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - - const { status, body } = await request(app) - .put(`/users/me`) - .send({ name: 'Name' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ - ...before, - updatedAt: expect.any(String), - profileChangedAt: expect.any(String), - createdAt: expect.any(String), - name: 'Name', - }); - }); - /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); @@ -112,189 +50,5 @@ describe('/users', () => { expect(token.accessToken).toBeDefined(); }); - - it('should not allow user to change to a taken email', async () => { - const { status, body } = await request(app) - .put(`/users/me`) - .send({ email: 'admin@immich.cloud' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - - expect(status).toBe(400); - expect(body).toMatchObject(errorDto.badRequest('Email is not available')); - }); - - it('should update my email', async () => { - const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); - const { status, body } = await request(app) - .put(`/users/me`) - .send({ email: 'non-admin@immich.cloud' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - email: 'non-admin@immich.cloud', - updatedAt: expect.anything(), - createdAt: expect.anything(), - profileChangedAt: expect.anything(), - }); - }); - - it('should update avatar color', async () => { - const { status, body } = await request(app) - .put(`/users/me`) - .send({ avatarColor: 'blue' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatarColor: 'blue' }); - - const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatarColor: 'blue' }); - }); - }); - - describe('PUT /users/me/preferences', () => { - it('should update memories enabled', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ memories: { enabled: true } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ memories: { enabled: false } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ memories: { enabled: false } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ memories: { enabled: false } }); - }); - - it('should update download archive size', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ download: { archiveSize: 4 * 2 ** 30 } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { archiveSize: 1_234_567 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); - }); - - it('should update download include embedded videos', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { includeEmbeddedVideos: true } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); - }); - - it('should update minimum face count to display people', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ people: { minimumFaces: 3 } }); - - const { status, body } = await request(app) - .put('/users/me/preferences') - .send({ people: { minimumFaces: 2 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ people: { minimumFaces: 2 } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ people: { minimumFaces: 2 } }); - }); - }); - - describe('GET /users/:id', () => { - it('should get the user', async () => { - const { status, body } = await request(app) - .get(`/users/${admin.userId}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - }); - - expect(body).not.toMatchObject({ - shouldChangePassword: expect.anything(), - storageLabel: expect.anything(), - }); - }); - }); - - describe('GET /server/license', () => { - it('should return the user license', async () => { - await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send(userLicense); - const { status, body } = await request(app) - .get('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - ...userLicense, - activatedAt: expect.any(String), - }); - }); - }); - - describe('PUT /users/me/license', () => { - it('should set the user license', async () => { - const { status, body } = await request(app) - .put(`/users/me/license`) - .send(userLicense) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) }); - expect(status).toBe(200); - expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) }); - const { body: licenseBody } = await request(app) - .get('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) }); - }); - - it('should reject license not starting with IMCL-', async () => { - const { status, body } = await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' }); - expect(status).toBe(400); - expect(body.message).toBe('Invalid license key'); - }); - - it('should reject license with invalid activation key', async () => { - const { status, body } = await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` }); - expect(status).toBe(400); - expect(body.message).toBe('Invalid license key'); - }); - }); - - describe('DELETE /users/me/license', () => { - it('should delete the user license', async () => { - const { status } = await request(app) - .delete(`/users/me/license`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(204); - }); }); }); diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 2eac06d674..85ab9a8c0d 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -1,6 +1,6 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; -import { ImmichEnvironment, JobName, JobStatus } from 'src/enum'; +import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -10,10 +10,17 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { UserService } from 'src/services/user.service'; +import { HumanReadableSize } from 'src/utils/bytes'; import { mediumFactory, newMediumService } from 'test/medium.factory'; import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; +const userLicense = { + licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', + activationKey: + 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', +}; + let defaultDatabase: Kysely; const setup = (db?: Kysely) => { @@ -38,9 +45,10 @@ describe(UserService.name, () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); const user = mediumFactory.userInsert(); - await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual( - expect.objectContaining({ name: user.name, email: user.email }), - ); + const created = await sut.createUser({ name: user.name, email: user.email }); + expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email })); + + await expect(sut.get(created.id)).resolves.toMatchObject({ name: user.name, email: user.email }); }); it('should reject user with duplicate email', async () => { @@ -97,6 +105,38 @@ describe(UserService.name, () => { expect((result as any).password).toBeUndefined(); }); + + it('should not expose private fields', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect(sut.get(user.id)).resolves.not.toMatchObject({ + shouldChangePassword: expect.anything(), + storageLabel: expect.anything(), + }); + }); + }); + + describe('getMe', () => { + it('should get my user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + await expect(sut.getMe(auth)).resolves.toEqual( + expect.objectContaining({ id: user.id, email: user.email, quotaUsageInBytes: 0 }), + ); + }); + + it('should include license info', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await sut.setLicense(auth, userLicense); + + await expect(sut.getMe(auth)).resolves.toMatchObject({ license: userLicense }); + }); }); describe('updateMe', () => { @@ -109,25 +149,169 @@ describe(UserService.name, () => { expect(before.updatedAt).toBeDefined(); expect(after.updatedAt).toBeDefined(); expect(before.updatedAt).not.toEqual(after.updatedAt); + + await expect(sut.getMe(auth)).resolves.toMatchObject({ + name: `${before.name} Updated`, + updatedAt: after.updatedAt, + }); + }); + + it('should update the name', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { name: 'Name' }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should update the email', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { email: 'updated@immich.cloud' }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should not allow an email that is already taken', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user2.id } }); + + await expect(sut.updateMe(auth, { email: user1.email })).rejects.toThrow('Email is not available'); + }); + + it('should update the avatar color', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { avatarColor: UserAvatarColor.Blue }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should clear shouldChangePassword when the password is updated', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser({ shouldChangePassword: true }); + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.updateMe(auth, { password: 'super-secret' })).resolves.toMatchObject({ + shouldChangePassword: false, + }); + await expect(sut.getMe(auth)).resolves.toMatchObject({ shouldChangePassword: false }); + }); + }); + + describe('updateMyPreferences', () => { + it('should update memories enabled', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { memories: { enabled: false } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ memories: { enabled: true } }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update the download archive size', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { download: { archiveSize: 1_234_567 } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ + download: { archiveSize: 4 * HumanReadableSize.GiB }, + }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update download include embedded videos', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { download: { includeEmbeddedVideos: true } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ + download: { includeEmbeddedVideos: false }, + }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update the minimum face count', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { people: { minimumFaces: 2 } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ people: { minimumFaces: 3 } }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); }); }); describe('setLicense', () => { it('should set a license', async () => { - const license = { - licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', - activationKey: - 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', - }; const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user: { id: user.id } }); await expect(sut.getLicense(auth)).rejects.toThrowError(); - const after = await sut.setLicense(auth, license); - expect(after.licenseKey).toEqual(license.licenseKey); - expect(after.activationKey).toEqual(license.activationKey); + const after = await sut.setLicense(auth, userLicense); + expect(after.licenseKey).toEqual(userLicense.licenseKey); + expect(after.activationKey).toEqual(userLicense.activationKey); const response = await sut.getLicense(auth); expect(response).toEqual(after); + await expect(sut.getMe(auth)).resolves.toMatchObject({ license: after }); + }); + + it('should reject a license key that does not start with IMCL-', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.setLicense(auth, { + licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', + activationKey: 'activationKey', + }), + ).rejects.toThrow('Invalid license key'); + }); + + it('should reject an invalid activation key', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.setLicense(auth, { ...userLicense, activationKey: `invalid${userLicense.activationKey}` }), + ).rejects.toThrow('Invalid license key'); + }); + }); + + describe('deleteLicense', () => { + it('should delete the license', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await sut.setLicense(auth, userLicense); + await sut.deleteLicense(auth); + + await expect(sut.getLicense(auth)).rejects.toThrowError(); }); });