import { LoginResponseDto, createStack, deleteUserAdmin, getMyUser, getUserAdmin, getUserPreferencesAdmin, login, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/admin/users', () => { let websocket: Socket; let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; let deletedUser: LoginResponseDto; let userToDelete: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); [websocket, nonAdmin, deletedUser, userToDelete] = await Promise.all([ utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user3), ]); await deleteUserAdmin( { id: deletedUser.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }, ); }); afterAll(() => { utils.disconnectWebsocket(websocket); }); describe('GET /admin/users', () => { it('should require authentication', async () => { const { status, body } = await request(app).get(`/admin/users`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); 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`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: admin.userEmail }), expect.objectContaining({ email: nonAdmin.userEmail }), expect.objectContaining({ email: userToDelete.userEmail }), ]), ); }); it('should include deleted users', async () => { const { status, body } = await request(app) .get(`/admin/users?withDeleted=true`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: admin.userEmail }), expect.objectContaining({ email: nonAdmin.userEmail }), expect.objectContaining({ email: userToDelete.userEmail }), expect.objectContaining({ email: deletedUser.userEmail }), ]), ); }); }); describe('POST /admin/users', () => { it('should require authentication', async () => { const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); 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); }); for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .post(`/admin/users`) .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(`/admin/users`) .send({ isAdmin: true, email: 'user5@immich.cloud', password: 'password123', name: 'Immich', }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ email: 'user5@immich.cloud', isAdmin: false, shouldChangePassword: true, }); expect(status).toBe(201); }); }); describe('PUT /admin/users/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); 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); }); for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ [key]: null }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest()); }); } it('should not allow a non-admin to become an admin', async () => { const { status, body } = await request(app) .put(`/admin/users/${nonAdmin.userId}`) .send({ isAdmin: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ isAdmin: false }); }); it('ignores updates to profileImagePath', async () => { const { status, body } = await request(app) .put(`/admin/users/${admin.userId}`) .send({ profileImagePath: 'invalid.jpg' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' }); }); it('should update first and last name', async () => { const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) .put(`/admin/users/${admin.userId}`) .send({ 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 password', async () => { const { status, body } = await request(app) .put(`/admin/users/${nonAdmin.userId}`) .send({ password: 'super-secret' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ email: nonAdmin.userEmail }); const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } }); expect(token.accessToken).toBeDefined(); const user = await getMyUser({ headers: asBearerAuth(token.accessToken) }); expect(user).toMatchObject({ email: nonAdmin.userEmail }); }); it('should update the avatar color', async () => { const { status, body } = await request(app) .put(`/admin/users/${admin.userId}`) .send({ avatarColor: 'orange' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ avatarColor: 'orange' }); const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ avatarColor: 'orange' }); }); }); describe('PUT /admin/users/:id/preferences', () => { it('should require authentication', async () => { const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should update memories enabled', async () => { const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toMatchObject({ memories: { enabled: true } }); const { status, body } = await request(app) .put(`/admin/users/${admin.userId}/preferences`) .send({ memories: { enabled: false } }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ memories: { enabled: false } }); const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ memories: { enabled: false } }); }); it('should update download archive size', async () => { const { status, body } = await request(app) .put(`/admin/users/${admin.userId}/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 getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); }); describe('DELETE /admin/users/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); 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}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: userToDelete.userId, updatedAt: expect.any(String), deletedAt: expect.any(String), }); }); it('should hard delete a user', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('hard-delete-1')); const { status, body } = await request(app) .delete(`/admin/users/${user.userId}`) .send({ force: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: user.userId, updatedAt: expect.any(String), deletedAt: expect.any(String), }); await utils.waitForWebsocketEvent({ event: 'userDelete', id: user.userId, timeout: 5000 }); }); it('should hard delete a user with stacked assets', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('hard-delete-1')); const [asset1, asset2] = await Promise.all([ utils.createAsset(user.accessToken), utils.createAsset(user.accessToken), ]); await createStack( { stackCreateDto: { assetIds: [asset1.id, asset2.id] } }, { headers: asBearerAuth(user.accessToken) }, ); const { status, body } = await request(app) .delete(`/admin/users/${user.userId}`) .send({ force: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: user.userId, updatedAt: expect.any(String), deletedAt: expect.any(String), }); await utils.waitForWebsocketEvent({ event: 'userDelete', id: user.userId, timeout: 5000 }); }); }); describe('POST /admin/users/:id/restore', () => { it('should require authentication', async () => { const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); 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')); await deleteUserAdmin({ id: user.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) .post(`/admin/users/${user.userId}/restore`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ id: user.userId, email: user.userEmail, status: 'active', deletedAt: null, }), ); }); }); });