From a2a9797fabbb840a3dc712852a08c010e2f5002f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 27 Jun 2025 15:35:19 -0400 Subject: [PATCH] refactor: auth medium tests (#19583) --- e2e/src/api/specs/auth.e2e-spec.ts | 146 ---------------- .../src/controllers/auth.controller.spec.ts | 45 +++++ server/test/medium.factory.ts | 32 +++- .../specs/services/auth.service.spec.ts | 163 ++++++++++++++++++ 4 files changed, 238 insertions(+), 148 deletions(-) delete mode 100644 e2e/src/api/specs/auth.e2e-spec.ts create mode 100644 server/test/medium/specs/services/auth.service.spec.ts diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts deleted file mode 100644 index 0f407f4ba7..0000000000 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk'; -import { loginDto, signupDto } from 'src/fixtures'; -import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; -import { app, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeEach, describe, expect, it } from 'vitest'; - -const { email, password } = signupDto.admin; - -describe(`/auth/admin-sign-up`, () => { - beforeEach(async () => { - await utils.resetDatabase(); - }); - - describe('POST /auth/admin-sign-up', () => { - it(`should sign up the admin`, async () => { - const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin); - expect(status).toBe(201); - expect(body).toEqual(signupResponseDto.admin); - }); - - it('should not allow a second admin to sign up', async () => { - await signUpAdmin({ signUpDto: signupDto.admin }); - - const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.alreadyHasAdmin); - }); - }); -}); - -describe('/auth/*', () => { - let admin: LoginResponseDto; - - beforeEach(async () => { - await utils.resetDatabase(); - await signUpAdmin({ signUpDto: signupDto.admin }); - admin = await login({ loginCredentialDto: loginDto.admin }); - }); - - describe(`POST /auth/login`, () => { - it('should reject an incorrect password', async () => { - const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.incorrectLogin); - }); - - it('should accept a correct password', async () => { - const { status, body, headers } = await request(app).post('/auth/login').send({ email, password }); - expect(status).toBe(201); - expect(body).toEqual(loginResponseDto.admin); - - const token = body.accessToken; - expect(token).toBeDefined(); - - const cookies = headers['set-cookie']; - expect(cookies).toHaveLength(3); - expect(cookies[0].split(';').map((item) => item.trim())).toEqual([ - `immich_access_token=${token}`, - 'Max-Age=34560000', - 'Path=/', - expect.stringContaining('Expires='), - 'HttpOnly', - 'SameSite=Lax', - ]); - expect(cookies[1].split(';').map((item) => item.trim())).toEqual([ - 'immich_auth_type=password', - 'Max-Age=34560000', - 'Path=/', - expect.stringContaining('Expires='), - 'HttpOnly', - 'SameSite=Lax', - ]); - expect(cookies[2].split(';').map((item) => item.trim())).toEqual([ - 'immich_is_authenticated=true', - 'Max-Age=34560000', - 'Path=/', - expect.stringContaining('Expires='), - 'SameSite=Lax', - ]); - }); - }); - - describe('POST /auth/validateToken', () => { - it('should reject an invalid token', async () => { - const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidToken); - }); - - it('should accept a valid token', async () => { - const { status, body } = await request(app) - .post(`/auth/validateToken`) - .send({}) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ authStatus: true }); - }); - }); - - describe('POST /auth/change-password', () => { - it('should require the current password', async () => { - const { status, body } = await request(app) - .post(`/auth/change-password`) - .send({ password: 'wrong-password', newPassword: 'Password1234' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.wrongPassword); - }); - - it('should change the password', async () => { - const { status } = await request(app) - .post(`/auth/change-password`) - .send({ password, newPassword: 'Password1234' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - - await login({ - loginCredentialDto: { - email: 'admin@immich.cloud', - password: 'Password1234', - }, - }); - }); - }); - - describe('POST /auth/logout', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/auth/logout`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should logout the user', async () => { - const { status, body } = await request(app) - .post(`/auth/logout`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - successful: true, - redirectUri: '/auth/login?autoLaunch=0', - }); - }); - }); -}); diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index 4129b24124..031ef460c2 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -2,6 +2,7 @@ import { AuthController } from 'src/controllers/auth.controller'; import { LoginResponseDto } from 'src/dtos/auth.dto'; import { AuthService } from 'src/services/auth.service'; import request from 'supertest'; +import { mediumFactory } from 'test/medium.factory'; import { errorDto } from 'test/medium/responses'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; @@ -132,6 +133,50 @@ describe(AuthController.name, () => { expect(status).toEqual(201); expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything()); }); + + it('should auth cookies on a secure connection', async () => { + const loginResponse = mediumFactory.loginResponse(); + service.login.mockResolvedValue(loginResponse); + const { status, body, headers } = await request(ctx.getHttpServer()) + .post('/auth/login') + .send({ name: 'admin', email: 'admin@local', password: 'password' }); + + expect(status).toEqual(201); + expect(body).toEqual(loginResponse); + + const cookies = headers['set-cookie']; + expect(cookies).toHaveLength(3); + expect(cookies[0].split(';').map((item) => item.trim())).toEqual([ + `immich_access_token=${loginResponse.accessToken}`, + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[1].split(';').map((item) => item.trim())).toEqual([ + 'immich_auth_type=password', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[2].split(';').map((item) => item.trim())).toEqual([ + 'immich_is_authenticated=true', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'SameSite=Lax', + ]); + }); + }); + + describe('POST /auth/logout', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/auth/logout'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); }); describe('POST /auth/change-password', () => { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 922c5d3fdb..a2dd36527c 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db'; -import { AuthDto } from 'src/dtos/auth.dto'; +import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -17,6 +17,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { EmailRepository } from 'src/repositories/email.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; @@ -305,6 +306,10 @@ const newMockRepository = (key: ClassConstructor) => { return automock(EmailRepository, { args: [{ setContext: () => {} }] }); } + case EventRepository: { + return automock(EventRepository, { args: [undefined, undefined, { setContext: () => {} }] }); + } + case JobRepository: { return automock(JobRepository, { args: [ @@ -461,10 +466,13 @@ const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial> = {}) => { const id = user.id || newUuid(); - const defaults: Insertable = { + const defaults = { email: `${id}@immich.cloud`, name: `User ${id}`, deletedAt: null, + isAdmin: false, + profileImagePath: '', + shouldChangePassword: true, }; return { ...defaults, ...user, id }; @@ -513,6 +521,24 @@ const syncStream = () => { return new CustomWritable(); }; +const loginDetails = () => { + return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' }; +}; + +const loginResponse = (): LoginResponseDto => { + const user = userInsert({}); + return { + accessToken: 'access-token', + userId: user.id, + userEmail: user.email, + name: user.name, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + shouldChangePassword: user.shouldChangePassword, + isOnboarded: false, + }; +}; + export const mediumFactory = { assetInsert, assetFaceInsert, @@ -524,4 +550,6 @@ export const mediumFactory = { syncStream, userInsert, memoryInsert, + loginDetails, + loginResponse, }; diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts new file mode 100644 index 0000000000..53deaa1291 --- /dev/null +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -0,0 +1,163 @@ +import { BadRequestException } from '@nestjs/common'; +import { hash } from 'bcrypt'; +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { AuthType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { AuthService } from 'src/services/auth.service'; +import { mediumFactory, 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(AuthService, { + database: db || defaultDatabase, + real: [ + AccessRepository, + ConfigRepository, + CryptoRepository, + DatabaseRepository, + SessionRepository, + SystemMetadataRepository, + UserRepository, + ], + mock: [LoggingRepository, StorageRepository, EventRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AuthService.name, () => { + describe('adminSignUp', () => { + it(`should sign up the admin`, async () => { + const { sut } = setup(); + const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' }; + + await expect(sut.adminSignUp(dto)).resolves.toEqual( + expect.objectContaining({ + id: expect.any(String), + email: dto.email, + name: dto.name, + isAdmin: true, + }), + ); + }); + + it('should not allow a second admin to sign up', async () => { + const { sut, ctx } = setup(); + await ctx.newUser({ isAdmin: true }); + const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' }; + + const response = sut.adminSignUp(dto); + await expect(response).rejects.toThrow(BadRequestException); + await expect(response).rejects.toThrow('The server already has an admin'); + }); + }); + + describe('login', () => { + it('should reject an incorrect password', async () => { + const { sut, ctx } = setup(); + const password = 'password'; + const passwordHashed = await hash(password, 10); + const { user } = await ctx.newUser({ password: passwordHashed }); + const dto = { email: user.email, password: 'wrong-password' }; + + await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password'); + }); + + it('should accept a correct password and return a login response', async () => { + const { sut, ctx } = setup(); + const password = 'password'; + const passwordHashed = await hash(password, 10); + const { user } = await ctx.newUser({ password: passwordHashed }); + const dto = { email: user.email, password }; + + await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({ + accessToken: expect.any(String), + isAdmin: user.isAdmin, + isOnboarded: false, + name: user.name, + profileImagePath: user.profileImagePath, + userId: user.id, + userEmail: user.email, + shouldChangePassword: user.shouldChangePassword, + }); + }); + }); + + describe('logout', () => { + it('should logout', async () => { + const { sut } = setup(); + const auth = factory.auth(); + await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + successful: true, + redirectUri: '/auth/login?autoLaunch=0', + }); + }); + + it('should cleanup the session', async () => { + const { sut, ctx } = setup(); + const sessionRepo = ctx.get(SessionRepository); + const eventRepo = ctx.getMock(EventRepository); + const { user } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user.id }); + const auth = factory.auth({ session, user }); + eventRepo.emit.mockResolvedValue(); + + await expect(sessionRepo.get(session.id)).resolves.toEqual(expect.objectContaining({ id: session.id })); + await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + successful: true, + redirectUri: '/auth/login?autoLaunch=0', + }); + await expect(sessionRepo.get(session.id)).resolves.toBeUndefined(); + }); + }); + + describe('changePassword', () => { + it('should change the password and login with it', async () => { + const { sut, ctx } = setup(); + const dto = { password: 'password', newPassword: 'new-password' }; + const passwordHashed = await hash(dto.password, 10); + const { user } = await ctx.newUser({ password: passwordHashed }); + const auth = factory.auth({ user }); + + const response = await sut.changePassword(auth, dto); + expect(response).toEqual( + expect.objectContaining({ + id: user.id, + email: user.email, + }), + ); + expect((response as any).password).not.toBeDefined(); + + await expect( + sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()), + ).resolves.toBeDefined(); + }); + + it('should validate the current password', async () => { + const { sut, ctx } = setup(); + const dto = { password: 'wrong-password', newPassword: 'new-password' }; + const passwordHashed = await hash('password', 10); + const { user } = await ctx.newUser({ password: passwordHashed }); + const auth = factory.auth({ user }); + + const response = sut.changePassword(auth, dto); + await expect(response).rejects.toThrow(BadRequestException); + await expect(response).rejects.toThrow('Wrong password'); + }); + }); +});