mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	test(server): auth e2e (#3492)
* test(server): auth controller e2e test * test(server): user e2e test * refactor(server): album e2e * fix: linting
This commit is contained in:
		| @@ -1,225 +0,0 @@ | |||||||
| import { |  | ||||||
|   AlbumResponseDto, |  | ||||||
|   AuthService, |  | ||||||
|   AuthUserDto, |  | ||||||
|   CreateAlbumDto, |  | ||||||
|   SharedLinkCreateDto, |  | ||||||
|   SharedLinkResponseDto, |  | ||||||
|   UserService, |  | ||||||
| } from '@app/domain'; |  | ||||||
| import { AppModule } from '@app/immich/app.module'; |  | ||||||
| import { SharedLinkType } from '@app/infra/entities'; |  | ||||||
| import { INestApplication } from '@nestjs/common'; |  | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; |  | ||||||
| import request from 'supertest'; |  | ||||||
| import { DataSource } from 'typeorm'; |  | ||||||
| import { authCustom, clearDb, getAuthUser } from '../test/test-utils'; |  | ||||||
|  |  | ||||||
| async function _createAlbum(app: INestApplication, data: CreateAlbumDto) { |  | ||||||
|   const res = await request(app.getHttpServer()).post('/album').send(data); |  | ||||||
|   expect(res.status).toEqual(201); |  | ||||||
|   return res.body as AlbumResponseDto; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function _createAlbumSharedLink(app: INestApplication, data: Omit<SharedLinkCreateDto, 'type'>) { |  | ||||||
|   const res = await request(app.getHttpServer()) |  | ||||||
|     .post('/shared-link') |  | ||||||
|     .send({ ...data, type: SharedLinkType.ALBUM }); |  | ||||||
|   expect(res.status).toEqual(201); |  | ||||||
|   return res.body as SharedLinkResponseDto; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| describe('Album', () => { |  | ||||||
|   let app: INestApplication; |  | ||||||
|   let database: DataSource; |  | ||||||
|  |  | ||||||
|   describe('without auth', () => { |  | ||||||
|     beforeAll(async () => { |  | ||||||
|       const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); |  | ||||||
|  |  | ||||||
|       app = moduleFixture.createNestApplication(); |  | ||||||
|       database = app.get(DataSource); |  | ||||||
|       await app.init(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     afterAll(async () => { |  | ||||||
|       await clearDb(database); |  | ||||||
|       await app.close(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     it('prevents fetching albums if not auth', async () => { |  | ||||||
|       const { status } = await request(app.getHttpServer()).get('/album'); |  | ||||||
|       expect(status).toEqual(401); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   describe('with auth', () => { |  | ||||||
|     let userService: UserService; |  | ||||||
|     let authService: AuthService; |  | ||||||
|     let authUser: AuthUserDto; |  | ||||||
|  |  | ||||||
|     beforeAll(async () => { |  | ||||||
|       const builder = Test.createTestingModule({ imports: [AppModule] }); |  | ||||||
|       authUser = getAuthUser(); |  | ||||||
|       const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile(); |  | ||||||
|  |  | ||||||
|       app = moduleFixture.createNestApplication(); |  | ||||||
|       userService = app.get(UserService); |  | ||||||
|       authService = app.get(AuthService); |  | ||||||
|       database = app.get(DataSource); |  | ||||||
|       await app.init(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     afterAll(async () => { |  | ||||||
|       await app.close(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('with empty DB', () => { |  | ||||||
|       it('rejects invalid shared param', async () => { |  | ||||||
|         const { status } = await request(app.getHttpServer()).get('/album?shared=invalid'); |  | ||||||
|         expect(status).toEqual(400); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('rejects invalid assetId param', async () => { |  | ||||||
|         const { status } = await request(app.getHttpServer()).get('/album?assetId=invalid'); |  | ||||||
|         expect(status).toEqual(400); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       // TODO - Until someone figure out how to passed in a logged in user to the request. |  | ||||||
|       //   it('creates an album', async () => { |  | ||||||
|       //     const data: CreateAlbumDto = { |  | ||||||
|       //       albumName: 'first albbum', |  | ||||||
|       //     }; |  | ||||||
|       //     const body = await _createAlbum(app, data); |  | ||||||
|       //     expect(body).toEqual( |  | ||||||
|       //       expect.objectContaining({ |  | ||||||
|       //         ownerId: authUser.id, |  | ||||||
|       //         albumName: data.albumName, |  | ||||||
|       //       }), |  | ||||||
|       //     ); |  | ||||||
|       //   }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('with albums in DB', () => { |  | ||||||
|       const userOneSharedUser = 'userOneSharedUser'; |  | ||||||
|       const userOneSharedLink = 'userOneSharedLink'; |  | ||||||
|       const userOneNotShared = 'userOneNotShared'; |  | ||||||
|       const userTwoSharedUser = 'userTwoSharedUser'; |  | ||||||
|       const userTwoSharedLink = 'userTwoSharedLink'; |  | ||||||
|       const userTwoNotShared = 'userTwoNotShared'; |  | ||||||
|       let userOne: AuthUserDto; |  | ||||||
|       let userTwo: AuthUserDto; |  | ||||||
|  |  | ||||||
|       beforeAll(async () => { |  | ||||||
|         // setup users |  | ||||||
|         const adminSignUpDto = await authService.adminSignUp({ |  | ||||||
|           email: 'one@test.com', |  | ||||||
|           password: '1234', |  | ||||||
|           firstName: 'one', |  | ||||||
|           lastName: 'test', |  | ||||||
|         }); |  | ||||||
|         userOne = { ...adminSignUpDto, isAdmin: true }; // TODO: find out why adminSignUp doesn't have isAdmin (maybe can just return UserResponseDto) |  | ||||||
|  |  | ||||||
|         userTwo = await userService.createUser({ |  | ||||||
|           email: 'two@test.com', |  | ||||||
|           password: '1234', |  | ||||||
|           firstName: 'two', |  | ||||||
|           lastName: 'test', |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         // add user one albums |  | ||||||
|         authUser = userOne; |  | ||||||
|         const userOneAlbums = await Promise.all([ |  | ||||||
|           _createAlbum(app, { albumName: userOneSharedUser, sharedWithUserIds: [userTwo.id] }), |  | ||||||
|           _createAlbum(app, { albumName: userOneSharedLink }), |  | ||||||
|           _createAlbum(app, { albumName: userOneNotShared }), |  | ||||||
|         ]); |  | ||||||
|  |  | ||||||
|         // add shared link to userOneSharedLink album |  | ||||||
|         await _createAlbumSharedLink(app, { albumId: userOneAlbums[1].id }); |  | ||||||
|  |  | ||||||
|         // add user two albums |  | ||||||
|         authUser = userTwo; |  | ||||||
|         const userTwoAlbums = await Promise.all([ |  | ||||||
|           _createAlbum(app, { albumName: userTwoSharedUser, sharedWithUserIds: [userOne.id] }), |  | ||||||
|           _createAlbum(app, { albumName: userTwoSharedLink }), |  | ||||||
|           _createAlbum(app, { albumName: userTwoNotShared }), |  | ||||||
|         ]); |  | ||||||
|  |  | ||||||
|         // add shared link to userTwoSharedLink album |  | ||||||
|         await _createAlbumSharedLink(app, { albumId: userTwoAlbums[1].id }); |  | ||||||
|  |  | ||||||
|         // set user one as authed for next requests |  | ||||||
|         authUser = userOne; |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       afterAll(async () => { |  | ||||||
|         await clearDb(database); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('returns the album collection including owned and shared', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get('/album'); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(3); |  | ||||||
|         expect(body).toEqual( |  | ||||||
|           expect.arrayContaining([ |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }), |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }), |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }), |  | ||||||
|           ]), |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('returns the album collection filtered by shared', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get('/album?shared=true'); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(3); |  | ||||||
|         expect(body).toEqual( |  | ||||||
|           expect.arrayContaining([ |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }), |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }), |  | ||||||
|             expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoSharedUser, shared: true }), |  | ||||||
|           ]), |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('returns the album collection filtered by NOT shared', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get('/album?shared=false'); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(1); |  | ||||||
|         expect(body).toEqual( |  | ||||||
|           expect.arrayContaining([ |  | ||||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }), |  | ||||||
|           ]), |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       // TODO: Add asset to album and test if it returns correctly. |  | ||||||
|       it('returns the album collection filtered by assetId', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get( |  | ||||||
|           '/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790', |  | ||||||
|         ); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(0); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       // TODO: Add asset to album and test if it returns correctly. |  | ||||||
|       it('returns the album collection filtered by assetId and ignores shared=true', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get( |  | ||||||
|           '/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790', |  | ||||||
|         ); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(0); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       // TODO: Add asset to album and test if it returns correctly. |  | ||||||
|       it('returns the album collection filtered by assetId and ignores shared=false', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get( |  | ||||||
|           '/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790', |  | ||||||
|         ); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(0); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @@ -1,206 +0,0 @@ | |||||||
| import { AuthService, AuthUserDto, CreateUserDto, UserResponseDto, UserService } from '@app/domain'; |  | ||||||
| import { AppModule } from '@app/immich/app.module'; |  | ||||||
| import { INestApplication } from '@nestjs/common'; |  | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; |  | ||||||
| import request from 'supertest'; |  | ||||||
| import { DataSource } from 'typeorm'; |  | ||||||
| import { authCustom, clearDb } from '../test/test-utils'; |  | ||||||
|  |  | ||||||
| function _createUser(userService: UserService, data: CreateUserDto) { |  | ||||||
|   return userService.createUser(data); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| describe('User', () => { |  | ||||||
|   let app: INestApplication; |  | ||||||
|   let database: DataSource; |  | ||||||
|  |  | ||||||
|   afterAll(async () => { |  | ||||||
|     await clearDb(database); |  | ||||||
|     await app.close(); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   describe('without auth', () => { |  | ||||||
|     beforeAll(async () => { |  | ||||||
|       const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); |  | ||||||
|  |  | ||||||
|       app = moduleFixture.createNestApplication(); |  | ||||||
|       database = app.get(DataSource); |  | ||||||
|       await app.init(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     afterAll(async () => { |  | ||||||
|       await app.close(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     it('prevents fetching users if not auth', async () => { |  | ||||||
|       const { status } = await request(app.getHttpServer()).get('/user'); |  | ||||||
|       expect(status).toEqual(401); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   describe('with admin auth', () => { |  | ||||||
|     let userService: UserService; |  | ||||||
|     let authService: AuthService; |  | ||||||
|     let authUser: AuthUserDto; |  | ||||||
|     let userOne: UserResponseDto; |  | ||||||
|  |  | ||||||
|     beforeAll(async () => { |  | ||||||
|       const builder = Test.createTestingModule({ imports: [AppModule] }); |  | ||||||
|       const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile(); |  | ||||||
|  |  | ||||||
|       app = moduleFixture.createNestApplication(); |  | ||||||
|       userService = app.get(UserService); |  | ||||||
|       authService = app.get(AuthService); |  | ||||||
|       database = app.get(DataSource); |  | ||||||
|       await app.init(); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('with users in DB', () => { |  | ||||||
|       const authUserEmail = 'auth-user@test.com'; |  | ||||||
|       const userOneEmail = 'one@test.com'; |  | ||||||
|       const userTwoEmail = 'two@test.com'; |  | ||||||
|  |  | ||||||
|       beforeAll(async () => { |  | ||||||
|         // first user must be admin |  | ||||||
|         const adminSignupResponseDto = await authService.adminSignUp({ |  | ||||||
|           firstName: 'auth-user', |  | ||||||
|           lastName: 'test', |  | ||||||
|           email: authUserEmail, |  | ||||||
|           password: '1234', |  | ||||||
|         }); |  | ||||||
|         authUser = { ...adminSignupResponseDto, isAdmin: true }; // TODO: find out why adminSignUp doesn't have isAdmin (maybe can just return UserResponseDto) |  | ||||||
|  |  | ||||||
|         [userOne] = await Promise.all([ |  | ||||||
|           _createUser(userService, { |  | ||||||
|             firstName: 'one', |  | ||||||
|             lastName: 'test', |  | ||||||
|             email: userOneEmail, |  | ||||||
|             password: '1234', |  | ||||||
|           }), |  | ||||||
|           _createUser(userService, { |  | ||||||
|             firstName: 'two', |  | ||||||
|             lastName: 'test', |  | ||||||
|             email: userTwoEmail, |  | ||||||
|             password: '1234', |  | ||||||
|           }), |  | ||||||
|         ]); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('fetches the user collection including the auth user', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false'); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toHaveLength(3); |  | ||||||
|         expect(body).toEqual( |  | ||||||
|           expect.arrayContaining([ |  | ||||||
|             { |  | ||||||
|               email: userOneEmail, |  | ||||||
|               firstName: 'one', |  | ||||||
|               lastName: 'test', |  | ||||||
|               id: expect.anything(), |  | ||||||
|               createdAt: expect.anything(), |  | ||||||
|               isAdmin: false, |  | ||||||
|               shouldChangePassword: true, |  | ||||||
|               profileImagePath: '', |  | ||||||
|               deletedAt: null, |  | ||||||
|               updatedAt: expect.anything(), |  | ||||||
|               oauthId: '', |  | ||||||
|               storageLabel: null, |  | ||||||
|               externalPath: null, |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|               email: userTwoEmail, |  | ||||||
|               firstName: 'two', |  | ||||||
|               lastName: 'test', |  | ||||||
|               id: expect.anything(), |  | ||||||
|               createdAt: expect.anything(), |  | ||||||
|               isAdmin: false, |  | ||||||
|               shouldChangePassword: true, |  | ||||||
|               profileImagePath: '', |  | ||||||
|               deletedAt: null, |  | ||||||
|               updatedAt: expect.anything(), |  | ||||||
|               oauthId: '', |  | ||||||
|               storageLabel: null, |  | ||||||
|               externalPath: null, |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|               email: authUserEmail, |  | ||||||
|               firstName: 'auth-user', |  | ||||||
|               lastName: 'test', |  | ||||||
|               id: expect.anything(), |  | ||||||
|               createdAt: expect.anything(), |  | ||||||
|               isAdmin: true, |  | ||||||
|               shouldChangePassword: true, |  | ||||||
|               profileImagePath: '', |  | ||||||
|               deletedAt: null, |  | ||||||
|               updatedAt: expect.anything(), |  | ||||||
|               oauthId: '', |  | ||||||
|               storageLabel: 'admin', |  | ||||||
|               externalPath: null, |  | ||||||
|             }, |  | ||||||
|           ]), |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('disallows admin user from creating a second admin account', async () => { |  | ||||||
|         const { status } = await request(app.getHttpServer()) |  | ||||||
|           .put('/user') |  | ||||||
|           .send({ |  | ||||||
|             ...userOne, |  | ||||||
|             isAdmin: true, |  | ||||||
|           }); |  | ||||||
|         expect(status).toEqual(400); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('ignores updates to createdAt, updatedAt and deletedAt', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()) |  | ||||||
|           .put('/user') |  | ||||||
|           .send({ |  | ||||||
|             ...userOne, |  | ||||||
|             createdAt: '2023-01-01T00:00:00.000Z', |  | ||||||
|             updatedAt: '2023-01-01T00:00:00.000Z', |  | ||||||
|             deletedAt: '2023-01-01T00:00:00.000Z', |  | ||||||
|           }); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toStrictEqual({ |  | ||||||
|           ...userOne, |  | ||||||
|           createdAt: new Date(userOne.createdAt).toISOString(), |  | ||||||
|           updatedAt: expect.anything(), |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('ignores updates to profileImagePath', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()) |  | ||||||
|           .put('/user') |  | ||||||
|           .send({ |  | ||||||
|             ...userOne, |  | ||||||
|             profileImagePath: 'invalid.jpg', |  | ||||||
|           }); |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toStrictEqual({ |  | ||||||
|           ...userOne, |  | ||||||
|           createdAt: new Date(userOne.createdAt).toISOString(), |  | ||||||
|           updatedAt: expect.anything(), |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       it('allows to update first and last name', async () => { |  | ||||||
|         const { status, body } = await request(app.getHttpServer()) |  | ||||||
|           .put('/user') |  | ||||||
|           .send({ |  | ||||||
|             ...userOne, |  | ||||||
|             firstName: 'newFirstName', |  | ||||||
|             lastName: 'newLastName', |  | ||||||
|           }); |  | ||||||
|  |  | ||||||
|         expect(status).toEqual(200); |  | ||||||
|         expect(body).toMatchObject({ |  | ||||||
|           ...userOne, |  | ||||||
|           createdAt: new Date(userOne.createdAt).toISOString(), |  | ||||||
|           updatedAt: expect.anything(), |  | ||||||
|           firstName: 'newFirstName', |  | ||||||
|           lastName: 'newLastName', |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @@ -17,7 +17,7 @@ | |||||||
|     "nest": "nest", |     "nest": "nest", | ||||||
|     "start:dev": "nest start --watch --", |     "start:dev": "nest start --watch --", | ||||||
|     "start:debug": "nest start --debug 0.0.0.0:9230 --watch --", |     "start:debug": "nest start --debug 0.0.0.0:9230 --watch --", | ||||||
|     "lint": "eslint \"src/**/*.ts\" \"e2e/**/*.ts\" --max-warnings 0", |     "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0", | ||||||
|     "lint:fix": "npm run lint -- --fix", |     "lint:fix": "npm run lint -- --fix", | ||||||
|     "check": "tsc --noEmit", |     "check": "tsc --noEmit", | ||||||
|     "check:code": "npm run format && npm run lint && npm run check", |     "check:code": "npm run format && npm run lint && npm run check", | ||||||
| @@ -26,7 +26,7 @@ | |||||||
|     "test:watch": "jest --watch", |     "test:watch": "jest --watch", | ||||||
|     "test:cov": "jest --coverage", |     "test:cov": "jest --coverage", | ||||||
|     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", |     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", | ||||||
|     "test:e2e": "jest --config jest-e2e.json --runInBand", |     "test:e2e": "jest --config test/e2e/jest-e2e.json --runInBand", | ||||||
|     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", |     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", | ||||||
|     "typeorm:migrations:create": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:create", |     "typeorm:migrations:create": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:create", | ||||||
|     "typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./src/infra/database.config.ts", |     "typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./src/infra/database.config.ts", | ||||||
|   | |||||||
| @@ -101,13 +101,13 @@ describe('AuthService', () => { | |||||||
|  |  | ||||||
|     it('should check the user exists', async () => { |     it('should check the user exists', async () => { | ||||||
|       userMock.getByEmail.mockResolvedValue(null); |       userMock.getByEmail.mockResolvedValue(null); | ||||||
|       await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException); |       await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); | ||||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); |       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     it('should check the user has a password', async () => { |     it('should check the user has a password', async () => { | ||||||
|       userMock.getByEmail.mockResolvedValue({} as UserEntity); |       userMock.getByEmail.mockResolvedValue({} as UserEntity); | ||||||
|       await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException); |       await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); | ||||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); |       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,12 +1,5 @@ | |||||||
| import { SystemConfig, UserEntity } from '@app/infra/entities'; | import { SystemConfig, UserEntity } from '@app/infra/entities'; | ||||||
| import { | import { BadRequestException, Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; | ||||||
|   BadRequestException, |  | ||||||
|   Inject, |  | ||||||
|   Injectable, |  | ||||||
|   InternalServerErrorException, |  | ||||||
|   Logger, |  | ||||||
|   UnauthorizedException, |  | ||||||
| } from '@nestjs/common'; |  | ||||||
| import cookieParser from 'cookie'; | import cookieParser from 'cookie'; | ||||||
| import { IncomingHttpHeaders } from 'http'; | import { IncomingHttpHeaders } from 'http'; | ||||||
| import { DateTime } from 'luxon'; | import { DateTime } from 'luxon'; | ||||||
| @@ -90,7 +83,7 @@ export class AuthService { | |||||||
|  |  | ||||||
|     if (!user) { |     if (!user) { | ||||||
|       this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); |       this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); | ||||||
|       throw new BadRequestException('Incorrect email or password'); |       throw new UnauthorizedException('Incorrect email or password'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return this.createLoginResponse(user, AuthType.PASSWORD, details); |     return this.createLoginResponse(user, AuthType.PASSWORD, details); | ||||||
| @@ -129,21 +122,16 @@ export class AuthService { | |||||||
|       throw new BadRequestException('The server already has an admin'); |       throw new BadRequestException('The server already has an admin'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     try { |     const admin = await this.userCore.createUser({ | ||||||
|       const admin = await this.userCore.createUser({ |       isAdmin: true, | ||||||
|         isAdmin: true, |       email: dto.email, | ||||||
|         email: dto.email, |       firstName: dto.firstName, | ||||||
|         firstName: dto.firstName, |       lastName: dto.lastName, | ||||||
|         lastName: dto.lastName, |       password: dto.password, | ||||||
|         password: dto.password, |       storageLabel: 'admin', | ||||||
|         storageLabel: 'admin', |     }); | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       return mapAdminSignupResponse(admin); |     return mapAdminSignupResponse(admin); | ||||||
|     } catch (error) { |  | ||||||
|       this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack); |  | ||||||
|       throw new InternalServerErrorException('Failed to register new admin user'); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> { |   async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> { | ||||||
|   | |||||||
| @@ -2,7 +2,6 @@ import { | |||||||
|   AdminSignupResponseDto, |   AdminSignupResponseDto, | ||||||
|   AuthDeviceResponseDto, |   AuthDeviceResponseDto, | ||||||
|   AuthService, |   AuthService, | ||||||
|   AuthType, |  | ||||||
|   AuthUserDto, |   AuthUserDto, | ||||||
|   ChangePasswordDto, |   ChangePasswordDto, | ||||||
|   IMMICH_ACCESS_COOKIE, |   IMMICH_ACCESS_COOKIE, | ||||||
| @@ -15,7 +14,7 @@ import { | |||||||
|   UserResponseDto, |   UserResponseDto, | ||||||
|   ValidateAccessTokenResponseDto, |   ValidateAccessTokenResponseDto, | ||||||
| } from '@app/domain'; | } from '@app/domain'; | ||||||
| import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common'; | import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common'; | ||||||
| import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger'; | import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger'; | ||||||
| import { Request, Response } from 'express'; | import { Request, Response } from 'express'; | ||||||
| import { Authenticated, AuthUser, GetLoginDetails, PublicRoute } from '../app.guard'; | import { Authenticated, AuthUser, GetLoginDetails, PublicRoute } from '../app.guard'; | ||||||
| @@ -54,36 +53,39 @@ export class AuthController { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Delete('devices') |   @Delete('devices') | ||||||
|  |   @HttpCode(HttpStatus.NO_CONTENT) | ||||||
|   logoutAuthDevices(@AuthUser() authUser: AuthUserDto): Promise<void> { |   logoutAuthDevices(@AuthUser() authUser: AuthUserDto): Promise<void> { | ||||||
|     return this.service.logoutDevices(authUser); |     return this.service.logoutDevices(authUser); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Delete('devices/:id') |   @Delete('devices/:id') | ||||||
|  |   @HttpCode(HttpStatus.NO_CONTENT) | ||||||
|   logoutAuthDevice(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> { |   logoutAuthDevice(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> { | ||||||
|     return this.service.logoutDevice(authUser, id); |     return this.service.logoutDevice(authUser, id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Post('validateToken') |   @Post('validateToken') | ||||||
|  |   @HttpCode(HttpStatus.OK) | ||||||
|   validateAccessToken(): ValidateAccessTokenResponseDto { |   validateAccessToken(): ValidateAccessTokenResponseDto { | ||||||
|     return { authStatus: true }; |     return { authStatus: true }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Post('change-password') |   @Post('change-password') | ||||||
|  |   @HttpCode(HttpStatus.OK) | ||||||
|   changePassword(@AuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> { |   changePassword(@AuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> { | ||||||
|     return this.service.changePassword(authUser, dto); |     return this.service.changePassword(authUser, dto); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Post('logout') |   @Post('logout') | ||||||
|  |   @HttpCode(HttpStatus.OK) | ||||||
|   logout( |   logout( | ||||||
|     @Req() req: Request, |     @Req() req: Request, | ||||||
|     @Res({ passthrough: true }) res: Response, |     @Res({ passthrough: true }) res: Response, | ||||||
|     @AuthUser() authUser: AuthUserDto, |     @AuthUser() authUser: AuthUserDto, | ||||||
|   ): Promise<LogoutResponseDto> { |   ): Promise<LogoutResponseDto> { | ||||||
|     const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE]; |  | ||||||
|  |  | ||||||
|     res.clearCookie(IMMICH_ACCESS_COOKIE); |     res.clearCookie(IMMICH_ACCESS_COOKIE); | ||||||
|     res.clearCookie(IMMICH_AUTH_TYPE_COOKIE); |     res.clearCookie(IMMICH_AUTH_TYPE_COOKIE); | ||||||
|  |  | ||||||
|     return this.service.logout(authUser, authType); |     return this.service.logout(authUser, (req.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -77,6 +77,7 @@ export class UserController { | |||||||
|     return this.service.restoreUser(authUser, userId); |     return this.service.restoreUser(authUser, userId); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   // TODO: replace with @Put(':id') | ||||||
|   @Put() |   @Put() | ||||||
|   updateUser(@AuthUser() authUser: AuthUserDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> { |   updateUser(@AuthUser() authUser: AuthUserDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> { | ||||||
|     return this.service.updateUser(authUser, updateUserDto); |     return this.service.updateUser(authUser, updateUserDto); | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								server/src/immich/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/src/immich/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | export * from './app.module'; | ||||||
|  | export * from './controllers'; | ||||||
							
								
								
									
										205
									
								
								server/test/e2e/album.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								server/test/e2e/album.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,205 @@ | |||||||
|  | import { LoginResponseDto } from '@app/domain'; | ||||||
|  | import { AlbumController, AppModule } from '@app/immich'; | ||||||
|  | import { SharedLinkType } from '@app/infra/entities'; | ||||||
|  | import { INestApplication } from '@nestjs/common'; | ||||||
|  | import { Test, TestingModule } from '@nestjs/testing'; | ||||||
|  | import request from 'supertest'; | ||||||
|  | import { errorStub } from '../fixtures'; | ||||||
|  | import { api, db } from '../test-utils'; | ||||||
|  |  | ||||||
|  | const user1SharedUser = 'user1SharedUser'; | ||||||
|  | const user1SharedLink = 'user1SharedLink'; | ||||||
|  | const user1NotShared = 'user1NotShared'; | ||||||
|  | const user2SharedUser = 'user2SharedUser'; | ||||||
|  | const user2SharedLink = 'user2SharedLink'; | ||||||
|  | const user2NotShared = 'user2NotShared'; | ||||||
|  |  | ||||||
|  | describe(`${AlbumController.name} (e2e)`, () => { | ||||||
|  |   let app: INestApplication; | ||||||
|  |   let server: any; | ||||||
|  |   let user1: LoginResponseDto; | ||||||
|  |   let user2: LoginResponseDto; | ||||||
|  |  | ||||||
|  |   beforeAll(async () => { | ||||||
|  |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
|  |       imports: [AppModule], | ||||||
|  |     }).compile(); | ||||||
|  |  | ||||||
|  |     app = await moduleFixture.createNestApplication().init(); | ||||||
|  |     server = app.getHttpServer(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   beforeEach(async () => { | ||||||
|  |     await db.reset(); | ||||||
|  |     await api.adminSignUp(server); | ||||||
|  |     const admin = await api.adminLogin(server); | ||||||
|  |  | ||||||
|  |     await api.userApi.create(server, admin.accessToken, { | ||||||
|  |       email: 'user1@immich.app', | ||||||
|  |       password: 'Password123', | ||||||
|  |       firstName: 'User 1', | ||||||
|  |       lastName: 'Test', | ||||||
|  |     }); | ||||||
|  |     user1 = await api.login(server, { email: 'user1@immich.app', password: 'Password123' }); | ||||||
|  |  | ||||||
|  |     await api.userApi.create(server, admin.accessToken, { | ||||||
|  |       email: 'user2@immich.app', | ||||||
|  |       password: 'Password123', | ||||||
|  |       firstName: 'User 2', | ||||||
|  |       lastName: 'Test', | ||||||
|  |     }); | ||||||
|  |     user2 = await api.login(server, { email: 'user2@immich.app', password: 'Password123' }); | ||||||
|  |  | ||||||
|  |     const user1Albums = await Promise.all([ | ||||||
|  |       api.albumApi.create(server, user1.accessToken, { | ||||||
|  |         albumName: user1SharedUser, | ||||||
|  |         sharedWithUserIds: [user2.userId], | ||||||
|  |       }), | ||||||
|  |       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink }), | ||||||
|  |       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared }), | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     // add shared link to user1SharedLink album | ||||||
|  |     await api.sharedLinkApi.create(server, user1.accessToken, { | ||||||
|  |       type: SharedLinkType.ALBUM, | ||||||
|  |       albumId: user1Albums[1].id, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const user2Albums = await Promise.all([ | ||||||
|  |       api.albumApi.create(server, user2.accessToken, { | ||||||
|  |         albumName: user2SharedUser, | ||||||
|  |         sharedWithUserIds: [user1.userId], | ||||||
|  |       }), | ||||||
|  |       api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), | ||||||
|  |       api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     // add shared link to user2SharedLink album | ||||||
|  |     await api.sharedLinkApi.create(server, user2.accessToken, { | ||||||
|  |       type: SharedLinkType.ALBUM, | ||||||
|  |       albumId: user2Albums[1].id, | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterAll(async () => { | ||||||
|  |     await db.disconnect(); | ||||||
|  |     await app.close(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /album', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).get('/album'); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should reject an invalid shared param', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?shared=invalid') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(400); | ||||||
|  |       expect(body).toEqual(errorStub.badRequest); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should reject an invalid assetId param', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?assetId=invalid') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(400); | ||||||
|  |       expect(body).toEqual(errorStub.badRequest); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return the album collection including owned and shared', async () => { | ||||||
|  |       const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(3); | ||||||
|  |       expect(body).toEqual( | ||||||
|  |         expect.arrayContaining([ | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), | ||||||
|  |         ]), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return the album collection filtered by shared', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?shared=true') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(3); | ||||||
|  |       expect(body).toEqual( | ||||||
|  |         expect.arrayContaining([ | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), | ||||||
|  |           expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }), | ||||||
|  |         ]), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return the album collection filtered by NOT shared', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?shared=false') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(1); | ||||||
|  |       expect(body).toEqual( | ||||||
|  |         expect.arrayContaining([ | ||||||
|  |           expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), | ||||||
|  |         ]), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // TODO: Add asset to album and test if it returns correctly. | ||||||
|  |     it('should return the album collection filtered by assetId', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // TODO: Add asset to album and test if it returns correctly. | ||||||
|  |     it('should return the album collection filtered by assetId and ignores shared=true', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // TODO: Add asset to album and test if it returns correctly. | ||||||
|  |     it('should return the album collection filtered by assetId and ignores shared=false', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get('/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||||
|  |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|  |       expect(status).toEqual(200); | ||||||
|  |       expect(body).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /album', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).post('/album').send({ albumName: 'New album' }); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should create an album', async () => { | ||||||
|  |       const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); | ||||||
|  |       expect(body).toEqual({ | ||||||
|  |         id: expect.any(String), | ||||||
|  |         createdAt: expect.any(String), | ||||||
|  |         updatedAt: expect.any(String), | ||||||
|  |         ownerId: user1.userId, | ||||||
|  |         albumName: 'New album', | ||||||
|  |         albumThumbnailAssetId: null, | ||||||
|  |         shared: false, | ||||||
|  |         sharedUsers: [], | ||||||
|  |         assets: [], | ||||||
|  |         assetCount: 0, | ||||||
|  |         owner: expect.objectContaining({ email: user1.userEmail }), | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										226
									
								
								server/test/e2e/auth.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								server/test/e2e/auth.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | |||||||
|  | import { AppModule, AuthController } from '@app/immich'; | ||||||
|  | import { INestApplication } from '@nestjs/common'; | ||||||
|  | import { Test, TestingModule } from '@nestjs/testing'; | ||||||
|  | import request from 'supertest'; | ||||||
|  | import { deviceStub, errorStub, loginResponseStub, signupResponseStub, signupStub, uuidStub } from '../fixtures'; | ||||||
|  | import { api, db } from '../test-utils'; | ||||||
|  |  | ||||||
|  | const firstName = 'Immich'; | ||||||
|  | const lastName = 'Admin'; | ||||||
|  | const password = 'Password123'; | ||||||
|  | const email = 'admin@immich.app'; | ||||||
|  |  | ||||||
|  | describe(`${AuthController.name} (e2e)`, () => { | ||||||
|  |   let app: INestApplication; | ||||||
|  |   let server: any; | ||||||
|  |   let accessToken: string; | ||||||
|  |  | ||||||
|  |   beforeAll(async () => { | ||||||
|  |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
|  |       imports: [AppModule], | ||||||
|  |     }).compile(); | ||||||
|  |  | ||||||
|  |     app = await moduleFixture.createNestApplication().init(); | ||||||
|  |     server = app.getHttpServer(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   beforeEach(async () => { | ||||||
|  |     await db.reset(); | ||||||
|  |     await api.adminSignUp(server); | ||||||
|  |     const response = await api.adminLogin(server); | ||||||
|  |     accessToken = response.accessToken; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterAll(async () => { | ||||||
|  |     await db.disconnect(); | ||||||
|  |     await app.close(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /auth/admin-sign-up', () => { | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       await db.reset(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const invalid = [ | ||||||
|  |       { should: 'require an email address', data: { firstName, lastName, password } }, | ||||||
|  |       { should: 'require a password', data: { firstName, lastName, email } }, | ||||||
|  |       { should: 'require a first name ', data: { lastName, email, password } }, | ||||||
|  |       { should: 'require a last name ', data: { firstName, email, password } }, | ||||||
|  |       { should: 'require a valid email', data: { firstName, lastName, email: 'immich', password } }, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     for (const { should, data } of invalid) { | ||||||
|  |       it(`should ${should}`, async () => { | ||||||
|  |         const { status, body } = await request(server).post('/auth/admin-sign-up').send(data); | ||||||
|  |         expect(status).toEqual(400); | ||||||
|  |         expect(body).toEqual(errorStub.badRequest); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     it(`should sign up the admin`, async () => { | ||||||
|  |       await api.adminSignUp(server); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should sign up the admin with a local domain', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post('/auth/admin-sign-up') | ||||||
|  |         .send({ ...signupStub, email: 'admin@local' }); | ||||||
|  |       expect(status).toEqual(201); | ||||||
|  |       expect(body).toEqual({ ...signupResponseStub, email: 'admin@local' }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should transform email to lower case', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post('/auth/admin-sign-up') | ||||||
|  |         .send({ ...signupStub, email: 'aDmIn@IMMICH.app' }); | ||||||
|  |       expect(status).toEqual(201); | ||||||
|  |       expect(body).toEqual(signupResponseStub); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should not allow a second admin to sign up', async () => { | ||||||
|  |       await api.adminSignUp(server); | ||||||
|  |  | ||||||
|  |       const { status, body } = await request(server).post('/auth/admin-sign-up').send(signupStub); | ||||||
|  |  | ||||||
|  |       expect(status).toBe(400); | ||||||
|  |       expect(body).toEqual(errorStub.alreadyHasAdmin); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe(`POST /auth/login`, () => { | ||||||
|  |     it('should reject an incorrect password', async () => { | ||||||
|  |       const { status, body } = await request(server).post('/auth/login').send({ email, password: 'incorrect' }); | ||||||
|  |       expect(body).toEqual(errorStub.incorrectLogin); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should accept a correct password', async () => { | ||||||
|  |       const { status, body, headers } = await request(server).post('/auth/login').send({ email, password }); | ||||||
|  |       expect(status).toBe(201); | ||||||
|  |       expect(body).toEqual(loginResponseStub.admin.response); | ||||||
|  |  | ||||||
|  |       const token = body.accessToken; | ||||||
|  |       expect(token).toBeDefined(); | ||||||
|  |  | ||||||
|  |       const cookies = headers['set-cookie']; | ||||||
|  |       expect(cookies).toHaveLength(2); | ||||||
|  |       expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`); | ||||||
|  |       expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /auth/devices', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).get('/auth/devices'); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should get a list of authorized devices', async () => { | ||||||
|  |       const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual([deviceStub.current]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('DELETE /auth/devices/:id', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).delete(`/auth/devices`); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should logout all devices (except the current one)', async () => { | ||||||
|  |       for (let i = 0; i < 5; i++) { | ||||||
|  |         await api.adminLogin(server); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       await expect(api.getAuthDevices(server, accessToken)).resolves.toHaveLength(6); | ||||||
|  |  | ||||||
|  |       const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(204); | ||||||
|  |  | ||||||
|  |       await api.validateToken(server, accessToken); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('DELETE /auth/devices/:id', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).delete(`/auth/devices/${uuidStub.notFound}`); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should logout a device', async () => { | ||||||
|  |       const [device] = await api.getAuthDevices(server, accessToken); | ||||||
|  |       const { status } = await request(server) | ||||||
|  |         .delete(`/auth/devices/${device.id}`) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(204); | ||||||
|  |  | ||||||
|  |       const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(response.body).toEqual(errorStub.invalidToken); | ||||||
|  |       expect(response.status).toBe(401); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /auth/validateToken', () => { | ||||||
|  |     it('should reject an invalid token', async () => { | ||||||
|  |       const { status, body } = await request(server).post(`/auth/validateToken`).set('Authorization', 'Bearer 123'); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.invalidToken); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should accept a valid token', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post(`/auth/validateToken`) | ||||||
|  |         .send({}) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual({ authStatus: true }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /auth/change-password', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post(`/auth/change-password`) | ||||||
|  |         .send({ password: 'Password123', newPassword: 'Password1234' }); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should require the current password', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post(`/auth/change-password`) | ||||||
|  |         .send({ password: 'wrong-password', newPassword: 'Password1234' }) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(400); | ||||||
|  |       expect(body).toEqual(errorStub.wrongPassword); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should change the password', async () => { | ||||||
|  |       const { status } = await request(server) | ||||||
|  |         .post(`/auth/change-password`) | ||||||
|  |         .send({ password: 'Password123', newPassword: 'Password1234' }) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |  | ||||||
|  |       await api.login(server, { email: 'admin@immich.app', password: 'Password1234' }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /auth/logout', () => { | ||||||
|  |     it('should require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).post(`/auth/logout`); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should logout the user', async () => { | ||||||
|  |       const { status, body } = await request(server).post(`/auth/logout`).set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0' }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -1,13 +1,16 @@ | |||||||
| { | { | ||||||
|   "moduleFileExtensions": ["js", "json", "ts"], |   "moduleFileExtensions": ["js", "json", "ts"], | ||||||
|   "modulePaths": ["<rootDir>"], |   "modulePaths": ["<rootDir>"], | ||||||
|   "rootDir": ".", |   "rootDir": "../..", | ||||||
|   "globalSetup": "<rootDir>/e2e/setup.ts", |   "globalSetup": "<rootDir>/test/e2e/setup.ts", | ||||||
|   "testEnvironment": "node", |   "testEnvironment": "node", | ||||||
|   "testRegex": ".e2e-spec.ts$", |   "testRegex": ".e2e-spec.ts$", | ||||||
|  |   "testTimeout": 15000, | ||||||
|   "transform": { |   "transform": { | ||||||
|     "^.+\\.(t|j)s$": "ts-jest" |     "^.+\\.(t|j)s$": "ts-jest" | ||||||
|   }, |   }, | ||||||
|  |   "collectCoverageFrom": ["<rootDir>/src/**/*.(t|j)s", "!<rootDir>/src/infra/**/*"], | ||||||
|  |   "coverageDirectory": "./coverage", | ||||||
|   "moduleNameMapper": { |   "moduleNameMapper": { | ||||||
|     "^@test(|/.*)$": "<rootDir>/test/$1", |     "^@test(|/.*)$": "<rootDir>/test/$1", | ||||||
|     "^@app/immich(|/.*)$": "<rootDir>/src/immich/$1", |     "^@app/immich(|/.*)$": "<rootDir>/src/immich/$1", | ||||||
| @@ -9,15 +9,12 @@ export default async () => { | |||||||
|     .withDatabase('immich') |     .withDatabase('immich') | ||||||
|     .withUsername('postgres') |     .withUsername('postgres') | ||||||
|     .withPassword('postgres') |     .withPassword('postgres') | ||||||
|  |     .withReuse() | ||||||
|     .start(); |     .start(); | ||||||
| 
 | 
 | ||||||
|   process.env.DB_PORT = String(pg.getMappedPort(5432)); |   process.env.DB_URL = pg.getConnectionUri(); | ||||||
|   process.env.DB_HOSTNAME = pg.getHost(); |  | ||||||
|   process.env.DB_USERNAME = pg.getUsername(); |  | ||||||
|   process.env.DB_PASSWORD = pg.getPassword(); |  | ||||||
|   process.env.DB_DATABASE_NAME = pg.getDatabase(); |  | ||||||
| 
 | 
 | ||||||
|   const redis = await new GenericContainer('redis').withExposedPorts(6379).start(); |   const redis = await new GenericContainer('redis').withExposedPorts(6379).withReuse().start(); | ||||||
| 
 | 
 | ||||||
|   process.env.REDIS_PORT = String(redis.getMappedPort(6379)); |   process.env.REDIS_PORT = String(redis.getMappedPort(6379)); | ||||||
|   process.env.REDIS_HOSTNAME = redis.getHost(); |   process.env.REDIS_HOSTNAME = redis.getHost(); | ||||||
							
								
								
									
										238
									
								
								server/test/e2e/user.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								server/test/e2e/user.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,238 @@ | |||||||
|  | import { LoginResponseDto } from '@app/domain'; | ||||||
|  | import { AppModule, UserController } from '@app/immich'; | ||||||
|  | import { INestApplication } from '@nestjs/common'; | ||||||
|  | import { Test, TestingModule } from '@nestjs/testing'; | ||||||
|  | import request from 'supertest'; | ||||||
|  | import { errorStub } from '../fixtures'; | ||||||
|  | import { api, db } from '../test-utils'; | ||||||
|  |  | ||||||
|  | describe(`${UserController.name}`, () => { | ||||||
|  |   let app: INestApplication; | ||||||
|  |   let server: any; | ||||||
|  |   let loginResponse: LoginResponseDto; | ||||||
|  |   let accessToken: string; | ||||||
|  |  | ||||||
|  |   beforeAll(async () => { | ||||||
|  |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
|  |       imports: [AppModule], | ||||||
|  |     }).compile(); | ||||||
|  |  | ||||||
|  |     app = await moduleFixture.createNestApplication().init(); | ||||||
|  |     server = app.getHttpServer(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   beforeEach(async () => { | ||||||
|  |     await db.reset(); | ||||||
|  |     await api.adminSignUp(server); | ||||||
|  |     loginResponse = await api.adminLogin(server); | ||||||
|  |     accessToken = loginResponse.accessToken; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterAll(async () => { | ||||||
|  |     await db.disconnect(); | ||||||
|  |     await app.close(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   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', | ||||||
|  |         firstName: `User 1`, | ||||||
|  |         lastName: 'Test', | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       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, { | ||||||
|  |         email: `user1@immich.app`, | ||||||
|  |         password: 'Password123', | ||||||
|  |         firstName: `User 1`, | ||||||
|  |         lastName: 'Test', | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       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({ email: 'user1@immich.app', password: 'Password123', firstName: 'Immich', lastName: 'User' }); | ||||||
|  |       expect(status).toBe(401); | ||||||
|  |       expect(body).toEqual(errorStub.unauthorized); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should ignore `isAdmin`', async () => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post(`/user`) | ||||||
|  |         .send({ | ||||||
|  |           isAdmin: true, | ||||||
|  |           email: 'user1@immich.app', | ||||||
|  |           password: 'Password123', | ||||||
|  |           firstName: 'Immich', | ||||||
|  |           lastName: 'User', | ||||||
|  |         }) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(body).toMatchObject({ | ||||||
|  |         email: 'user1@immich.app', | ||||||
|  |         isAdmin: false, | ||||||
|  |         shouldChangePassword: true, | ||||||
|  |       }); | ||||||
|  |       expect(status).toBe(201); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   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); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     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', | ||||||
|  |         firstName: 'Immich', | ||||||
|  |         lastName: '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, | ||||||
|  |         firstName: 'First Name', | ||||||
|  |         lastName: 'Last Name', | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       expect(after).toMatchObject({ | ||||||
|  |         ...before, | ||||||
|  |         updatedAt: expect.anything(), | ||||||
|  |         firstName: 'First Name', | ||||||
|  |         lastName: 'Last Name', | ||||||
|  |       }); | ||||||
|  |       expect(before.updatedAt).not.toEqual(after.updatedAt); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /user/count', () => { | ||||||
|  |     it('should not require authentication', async () => { | ||||||
|  |       const { status, body } = await request(server).get(`/user/count`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual({ userCount: 1 }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should start with just the admin', async () => { | ||||||
|  |       const { status, body } = await request(server).get(`/user/count`).set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual({ userCount: 1 }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return the total user count', async () => { | ||||||
|  |       for (let i = 0; i < 5; i++) { | ||||||
|  |         await api.userApi.create(server, accessToken, { | ||||||
|  |           email: `user${i + 1}@immich.app`, | ||||||
|  |           password: 'Password123', | ||||||
|  |           firstName: `User ${i + 1}`, | ||||||
|  |           lastName: 'Test', | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |       const { status, body } = await request(server).get(`/user/count`).set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toEqual({ userCount: 6 }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										34
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										34
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,27 @@ | |||||||
| import { AuthUserDto } from '@app/domain'; | import { AuthUserDto } from '@app/domain'; | ||||||
|  |  | ||||||
|  | export const signupStub = { | ||||||
|  |   firstName: 'Immich', | ||||||
|  |   lastName: 'Admin', | ||||||
|  |   email: 'admin@immich.app', | ||||||
|  |   password: 'Password123', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const signupResponseStub = { | ||||||
|  |   id: expect.any(String), | ||||||
|  |   email: 'admin@immich.app', | ||||||
|  |   firstName: 'Immich', | ||||||
|  |   lastName: 'Admin', | ||||||
|  |   createdAt: expect.any(String), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const loginStub = { | ||||||
|  |   admin: { | ||||||
|  |     email: 'admin@immich.app', | ||||||
|  |     password: 'Password123', | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const authStub = { | export const authStub = { | ||||||
|   admin: Object.freeze<AuthUserDto>({ |   admin: Object.freeze<AuthUserDto>({ | ||||||
|     id: 'admin_id', |     id: 'admin_id', | ||||||
| @@ -76,6 +98,18 @@ export const authStub = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| export const loginResponseStub = { | export const loginResponseStub = { | ||||||
|  |   admin: { | ||||||
|  |     response: { | ||||||
|  |       accessToken: expect.any(String), | ||||||
|  |       firstName: 'Immich', | ||||||
|  |       isAdmin: true, | ||||||
|  |       lastName: 'Admin', | ||||||
|  |       profileImagePath: '', | ||||||
|  |       shouldChangePassword: true, | ||||||
|  |       userEmail: 'admin@immich.app', | ||||||
|  |       userId: expect.any(String), | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|   user1oauth: { |   user1oauth: { | ||||||
|     response: { |     response: { | ||||||
|       accessToken: 'cmFuZG9tLWJ5dGVz', |       accessToken: 'cmFuZG9tLWJ5dGVz', | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								server/test/fixtures/device.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/test/fixtures/device.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | export const deviceStub = { | ||||||
|  |   current: { | ||||||
|  |     id: expect.any(String), | ||||||
|  |     createdAt: expect.any(String), | ||||||
|  |     updatedAt: expect.any(String), | ||||||
|  |     current: true, | ||||||
|  |     deviceOS: '', | ||||||
|  |     deviceType: '', | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										32
									
								
								server/test/fixtures/error.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								server/test/fixtures/error.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | export const errorStub = { | ||||||
|  |   unauthorized: { | ||||||
|  |     error: 'Unauthorized', | ||||||
|  |     statusCode: 401, | ||||||
|  |     message: 'Authentication required', | ||||||
|  |   }, | ||||||
|  |   wrongPassword: { | ||||||
|  |     error: 'Bad Request', | ||||||
|  |     statusCode: 400, | ||||||
|  |     message: 'Wrong password', | ||||||
|  |   }, | ||||||
|  |   invalidToken: { | ||||||
|  |     error: 'Unauthorized', | ||||||
|  |     statusCode: 401, | ||||||
|  |     message: 'Invalid user token', | ||||||
|  |   }, | ||||||
|  |   badRequest: { | ||||||
|  |     error: 'Bad Request', | ||||||
|  |     statusCode: 400, | ||||||
|  |     message: expect.any(Array), | ||||||
|  |   }, | ||||||
|  |   incorrectLogin: { | ||||||
|  |     error: 'Unauthorized', | ||||||
|  |     statusCode: 401, | ||||||
|  |     message: 'Incorrect email or password', | ||||||
|  |   }, | ||||||
|  |   alreadyHasAdmin: { | ||||||
|  |     error: 'Bad Request', | ||||||
|  |     statusCode: 400, | ||||||
|  |     message: 'The server already has an admin', | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										3
									
								
								server/test/fixtures/index.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								server/test/fixtures/index.ts
									
									
									
									
										vendored
									
									
								
							| @@ -2,6 +2,8 @@ export * from './album.stub'; | |||||||
| export * from './api-key.stub'; | export * from './api-key.stub'; | ||||||
| export * from './asset.stub'; | export * from './asset.stub'; | ||||||
| export * from './auth.stub'; | export * from './auth.stub'; | ||||||
|  | export * from './device.stub'; | ||||||
|  | export * from './error.stub'; | ||||||
| export * from './face.stub'; | export * from './face.stub'; | ||||||
| export * from './file.stub'; | export * from './file.stub'; | ||||||
| export * from './media.stub'; | export * from './media.stub'; | ||||||
| @@ -13,3 +15,4 @@ export * from './system-config.stub'; | |||||||
| export * from './tag.stub'; | export * from './tag.stub'; | ||||||
| export * from './user-token.stub'; | export * from './user-token.stub'; | ||||||
| export * from './user.stub'; | export * from './user.stub'; | ||||||
|  | export * from './uuid.stub'; | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								server/test/fixtures/uuid.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/test/fixtures/uuid.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | export const uuidStub = { | ||||||
|  |   invalid: 'invalid-uuid', | ||||||
|  |   notFound: '00000000-0000-0000-0000-000000000000', | ||||||
|  | }; | ||||||
| @@ -1,18 +1,43 @@ | |||||||
| import { AuthUserDto } from '@app/domain'; | import { | ||||||
| import { AppGuard } from '@app/immich/app.guard'; |   AdminSignupResponseDto, | ||||||
| import { CanActivate, ExecutionContext } from '@nestjs/common'; |   AlbumResponseDto, | ||||||
| import { TestingModuleBuilder } from '@nestjs/testing'; |   AuthDeviceResponseDto, | ||||||
| import { DataSource } from 'typeorm'; |   AuthUserDto, | ||||||
|  |   CreateAlbumDto, | ||||||
|  |   CreateUserDto, | ||||||
|  |   LoginCredentialDto, | ||||||
|  |   LoginResponseDto, | ||||||
|  |   SharedLinkCreateDto, | ||||||
|  |   SharedLinkResponseDto, | ||||||
|  |   UpdateUserDto, | ||||||
|  |   UserResponseDto, | ||||||
|  | } from '@app/domain'; | ||||||
|  | import { dataSource } from '@app/infra'; | ||||||
|  | import request from 'supertest'; | ||||||
|  | import { loginResponseStub, loginStub, signupResponseStub, signupStub } from './fixtures'; | ||||||
|  |  | ||||||
| type CustomAuthCallback = () => AuthUserDto; | export const db = { | ||||||
|  |   reset: async () => { | ||||||
|  |     if (!dataSource.isInitialized) { | ||||||
|  |       await dataSource.initialize(); | ||||||
|  |     } | ||||||
|  |  | ||||||
| export async function clearDb(db: DataSource) { |     await dataSource.transaction(async (em) => { | ||||||
|   const entities = db.entityMetadatas; |       for (const entity of dataSource.entityMetadatas) { | ||||||
|   for (const entity of entities) { |         if (entity.tableName === 'users') { | ||||||
|     const repository = db.getRepository(entity.name); |           continue; | ||||||
|     await repository.query(`TRUNCATE ${entity.tableName} RESTART IDENTITY CASCADE;`); |         } | ||||||
|   } |         await em.query(`DELETE FROM ${entity.tableName} CASCADE;`); | ||||||
| } |       } | ||||||
|  |       await em.query(`DELETE FROM "users" CASCADE;`); | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  |   disconnect: async () => { | ||||||
|  |     if (dataSource.isInitialized) { | ||||||
|  |       await dataSource.destroy(); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
| export function getAuthUser(): AuthUserDto { | export function getAuthUser(): AuthUserDto { | ||||||
|   return { |   return { | ||||||
| @@ -22,17 +47,109 @@ export function getAuthUser(): AuthUserDto { | |||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
| export function auth(builder: TestingModuleBuilder): TestingModuleBuilder { | export const api = { | ||||||
|   return authCustom(builder, getAuthUser); |   adminSignUp: async (server: any) => { | ||||||
| } |     const { status, body } = await request(server).post('/auth/admin-sign-up').send(signupStub); | ||||||
|  |  | ||||||
| export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCallback): TestingModuleBuilder { |     expect(status).toBe(201); | ||||||
|   const canActivate: CanActivate = { |     expect(body).toEqual(signupResponseStub); | ||||||
|     canActivate: (context: ExecutionContext) => { |  | ||||||
|       const req = context.switchToHttp().getRequest(); |     return body as AdminSignupResponseDto; | ||||||
|       req.user = callback(); |   }, | ||||||
|       return true; |   adminLogin: async (server: any) => { | ||||||
|  |     const { status, body } = await request(server).post('/auth/login').send(loginStub.admin); | ||||||
|  |  | ||||||
|  |     expect(body).toEqual(loginResponseStub.admin.response); | ||||||
|  |     expect(body).toMatchObject({ accessToken: expect.any(String) }); | ||||||
|  |     expect(status).toBe(201); | ||||||
|  |  | ||||||
|  |     return body as LoginResponseDto; | ||||||
|  |   }, | ||||||
|  |   login: async (server: any, dto: LoginCredentialDto) => { | ||||||
|  |     const { status, body } = await request(server).post('/auth/login').send(dto); | ||||||
|  |  | ||||||
|  |     expect(status).toEqual(201); | ||||||
|  |     expect(body).toMatchObject({ accessToken: expect.any(String) }); | ||||||
|  |  | ||||||
|  |     return body as LoginResponseDto; | ||||||
|  |   }, | ||||||
|  |   getAuthDevices: async (server: any, accessToken: string) => { | ||||||
|  |     const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |  | ||||||
|  |     expect(body).toEqual(expect.any(Array)); | ||||||
|  |     expect(status).toBe(200); | ||||||
|  |  | ||||||
|  |     return body as AuthDeviceResponseDto[]; | ||||||
|  |   }, | ||||||
|  |   validateToken: async (server: any, accessToken: string) => { | ||||||
|  |     const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |     expect(response.body).toEqual({ authStatus: true }); | ||||||
|  |     expect(response.status).toBe(200); | ||||||
|  |   }, | ||||||
|  |   albumApi: { | ||||||
|  |     create: async (server: any, accessToken: string, dto: CreateAlbumDto) => { | ||||||
|  |       const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto); | ||||||
|  |       expect(res.status).toEqual(201); | ||||||
|  |       return res.body as AlbumResponseDto; | ||||||
|     }, |     }, | ||||||
|   }; |   }, | ||||||
|   return builder.overrideProvider(AppGuard).useValue(canActivate); |   sharedLinkApi: { | ||||||
| } |     create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post('/shared-link') | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`) | ||||||
|  |         .send(dto); | ||||||
|  |       expect(status).toBe(201); | ||||||
|  |       return body as SharedLinkResponseDto; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   userApi: { | ||||||
|  |     create: async (server: any, accessToken: string, dto: CreateUserDto) => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .post('/user') | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`) | ||||||
|  |         .send(dto); | ||||||
|  |  | ||||||
|  |       expect(status).toBe(201); | ||||||
|  |       expect(body).toMatchObject({ | ||||||
|  |         id: expect.any(String), | ||||||
|  |         createdAt: expect.any(String), | ||||||
|  |         updatedAt: expect.any(String), | ||||||
|  |         email: dto.email, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return body as UserResponseDto; | ||||||
|  |     }, | ||||||
|  |     get: async (server: any, accessToken: string, id: string) => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .get(`/user/info/${id}`) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |  | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toMatchObject({ id }); | ||||||
|  |  | ||||||
|  |       return body as UserResponseDto; | ||||||
|  |     }, | ||||||
|  |     update: async (server: any, accessToken: string, dto: UpdateUserDto) => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .put('/user') | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`) | ||||||
|  |         .send(dto); | ||||||
|  |  | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toMatchObject({ id: dto.id }); | ||||||
|  |  | ||||||
|  |       return body as UserResponseDto; | ||||||
|  |     }, | ||||||
|  |     delete: async (server: any, accessToken: string, id: string) => { | ||||||
|  |       const { status, body } = await request(server) | ||||||
|  |         .delete(`/user/${id}`) | ||||||
|  |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|  |  | ||||||
|  |       expect(status).toBe(200); | ||||||
|  |       expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); | ||||||
|  |  | ||||||
|  |       return body as UserResponseDto; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | } as const; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user