diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index e871691ed5..1b653a781f 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -5,7 +5,7 @@ import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; -const { name, email, password } = signupDto.admin; +const { email, password } = signupDto.admin; describe(`/auth/admin-sign-up`, () => { beforeEach(async () => { @@ -13,33 +13,6 @@ describe(`/auth/admin-sign-up`, () => { }); describe('POST /auth/admin-sign-up', () => { - const invalid = [ - { - should: 'require an email address', - data: { name, password }, - }, - { - should: 'require a password', - data: { name, email }, - }, - { - should: 'require a name', - data: { email, password }, - }, - { - should: 'require a valid email', - data: { name, email: 'immich', password }, - }, - ]; - - for (const { should, data } of invalid) { - it(`should ${should}`, async () => { - const { status, body } = await request(app).post('/auth/admin-sign-up').send(data); - expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); - }); - } - 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); @@ -57,14 +30,6 @@ describe(`/auth/admin-sign-up`, () => { }); }); - it('should transform email to lower case', async () => { - const { status, body } = await request(app) - .post('/auth/admin-sign-up') - .send({ ...signupDto.admin, email: 'aDmIn@IMMICH.cloud' }); - expect(status).toEqual(201); - expect(body).toEqual(signupResponseDto.admin); - }); - it('should not allow a second admin to sign up', async () => { await signUpAdmin({ signUpDto: signupDto.admin }); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 9cffa5d754..54d11e5049 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -31,33 +31,7 @@ describe('/users', () => { ); }); - describe('GET /users', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/users'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get users', async () => { - const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'admin@immich.cloud' }), - expect.objectContaining({ email: 'user2@immich.cloud' }), - ]), - ); - }); - }); - describe('GET /users/me', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/users/me`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should not work for shared links', async () => { const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' }); const sharedLink = await utils.createSharedLink(admin.accessToken, { @@ -99,24 +73,6 @@ describe('/users', () => { }); describe('PUT /users/me', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/users/me`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const key of ['email', 'name']) { - it(`should not allow null ${key}`, async () => { - const dto = { [key]: null }; - const { status, body } = await request(app) - .put(`/users/me`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(dto); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - } - it('should update first and last name', async () => { const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); @@ -269,11 +225,6 @@ describe('/users', () => { }); describe('GET /users/:id', () => { - it('should require authentication', async () => { - const { status } = await request(app).get(`/users/${admin.userId}`); - expect(status).toEqual(401); - }); - it('should get the user', async () => { const { status, body } = await request(app) .get(`/users/${admin.userId}`) @@ -292,12 +243,6 @@ describe('/users', () => { }); describe('GET /server/license', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/users/me/license'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should return the user license', async () => { await request(app) .put('/users/me/license') @@ -315,11 +260,6 @@ describe('/users', () => { }); describe('PUT /users/me/license', () => { - it('should require authentication', async () => { - const { status } = await request(app).put(`/users/me/license`); - expect(status).toEqual(401); - }); - it('should set the user license', async () => { const { status, body } = await request(app) .put(`/users/me/license`) diff --git a/server/package-lock.json b/server/package-lock.json index b5cbbc649c..8045976b3c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -114,6 +114,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "supertest": "^7.1.0", "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", @@ -7060,6 +7061,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -7999,6 +8007,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -8129,6 +8147,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.41.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", @@ -8446,6 +8471,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -9787,6 +9823,21 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", + "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^2.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -10325,6 +10376,16 @@ "he": "bin/he" } }, + "node_modules/hexoid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", + "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -11511,6 +11572,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -11536,6 +11607,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -15158,6 +15242,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.0.tgz", + "integrity": "sha512-5QeSO8hSrKghtcWEoPiO036fxH0Ii2wVQfFZSP0oqQhmjk8bOLhDFXr4JrvaFmPuEWUoq4znY3uSi8UzLKxGqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^9.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/server/package.json b/server/package.json index 451f6c581d..76415da7c8 100644 --- a/server/package.json +++ b/server/package.json @@ -140,6 +140,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "supertest": "^7.1.0", "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c8c806f4a4..5720f7af0b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -27,7 +27,7 @@ import { getKyselyConfig } from 'src/utils/database'; const common = [...repositories, ...services, GlobalExceptionFilter]; -const middleware = [ +export const middleware = [ FileUploadInterceptor, { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 9fa8b6243c..961cccbf3e 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; @@ -418,6 +419,7 @@ class TagAccess { } } +@Injectable() export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index ce356b898b..d3ab876e07 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -34,7 +34,7 @@ import { Mocked } from 'vitest'; const sha256 = (value: string) => createHash('sha256').update(value).digest('base64'); // type Repositories = Omit; -type Repositories = { +type RepositoriesTypes = { activity: ActivityRepository; album: AlbumRepository; asset: AssetRepository; @@ -54,22 +54,22 @@ type Repositories = { systemMetadata: SystemMetadataRepository; versionHistory: VersionHistoryRepository; }; -type RepositoryMocks = { [K in keyof Repositories]: Mocked> }; -type RepositoryOptions = Partial<{ [K in keyof Repositories]: 'mock' | 'real' }>; +type RepositoryMocks = { [K in keyof RepositoriesTypes]: Mocked> }; +type RepositoryOptions = Partial<{ [K in keyof RepositoriesTypes]: 'mock' | 'real' }>; type ContextRepositoryMocks = { - [K in keyof Repositories as R[K] extends 'mock' ? K : never]: Mocked>; + [K in keyof RepositoriesTypes as R[K] extends 'mock' ? K : never]: Mocked>; }; type ContextRepositories = { - [K in keyof Repositories as R[K] extends 'real' ? K : never]: Repositories[K]; + [K in keyof RepositoriesTypes as R[K] extends 'real' ? K : never]: RepositoriesTypes[K]; }; export type Context = { sut: S; mocks: ContextRepositoryMocks; repos: ContextRepositories; - getRepository(key: T): Repositories[T]; + getRepository(key: T): RepositoriesTypes[T]; }; export const newMediumService = ( @@ -79,7 +79,7 @@ export const newMediumService = => { - const repos: Partial = {}; + const repos: Partial = {}; const mocks: Partial = {}; const loggerMock = getRepositoryMock('logger') as Mocked; @@ -88,7 +88,7 @@ export const newMediumService = (key: K) => { + const makeRepository = (key: K) => { return repos[key] || getRepository(key, options.database); }; @@ -115,7 +115,7 @@ export const newMediumService = ; }; -export const getRepository = (key: K, db: Kysely) => { +export const getRepository = (key: K, db: Kysely) => { switch (key) { case 'activity': { return new ActivityRepository(db); @@ -189,10 +189,10 @@ export const getRepository = (key: K, db: Kysely(key: K) => { +const getRepositoryMock = (key: K) => { switch (key) { case 'activity': { - return automock(ActivityRepository); + return automock(ActivityRepository) as Mocked>; } case 'album': { diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts new file mode 100644 index 0000000000..0148f2e1e9 --- /dev/null +++ b/server/test/medium/responses.ts @@ -0,0 +1,121 @@ +import { expect } from 'vitest'; + +export const errorDto = { + unauthorized: { + error: 'Unauthorized', + statusCode: 401, + message: 'Authentication required', + correlationId: expect.any(String), + }, + forbidden: { + error: 'Forbidden', + statusCode: 403, + message: expect.any(String), + correlationId: expect.any(String), + }, + missingPermission: (permission: string) => ({ + error: 'Forbidden', + statusCode: 403, + message: `Missing required permission: ${permission}`, + correlationId: expect.any(String), + }), + wrongPassword: { + error: 'Bad Request', + statusCode: 400, + message: 'Wrong password', + correlationId: expect.any(String), + }, + invalidToken: { + error: 'Unauthorized', + statusCode: 401, + message: 'Invalid user token', + correlationId: expect.any(String), + }, + invalidShareKey: { + error: 'Unauthorized', + statusCode: 401, + message: 'Invalid share key', + correlationId: expect.any(String), + }, + invalidSharePassword: { + error: 'Unauthorized', + statusCode: 401, + message: 'Invalid password', + correlationId: expect.any(String), + }, + badRequest: (message: any = null) => ({ + error: 'Bad Request', + statusCode: 400, + message: message ?? expect.anything(), + correlationId: expect.any(String), + }), + noPermission: { + error: 'Bad Request', + statusCode: 400, + message: expect.stringContaining('Not found or no'), + correlationId: expect.any(String), + }, + incorrectLogin: { + error: 'Unauthorized', + statusCode: 401, + message: 'Incorrect email or password', + correlationId: expect.any(String), + }, + alreadyHasAdmin: { + error: 'Bad Request', + statusCode: 400, + message: 'The server already has an admin', + correlationId: expect.any(String), + }, + invalidEmail: { + error: 'Bad Request', + statusCode: 400, + message: ['email must be an email'], + correlationId: expect.any(String), + }, +}; + +export const signupResponseDto = { + admin: { + avatarColor: expect.any(String), + id: expect.any(String), + name: 'Immich Admin', + email: 'admin@immich.cloud', + storageLabel: 'admin', + profileImagePath: '', + // why? lol + shouldChangePassword: true, + isAdmin: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + deletedAt: null, + oauthId: '', + quotaUsageInBytes: 0, + quotaSizeInBytes: null, + status: 'active', + license: null, + profileChangedAt: expect.any(String), + }, +}; + +export const loginResponseDto = { + admin: { + accessToken: expect.any(String), + name: 'Immich Admin', + isAdmin: true, + profileImagePath: '', + shouldChangePassword: true, + userEmail: 'admin@immich.cloud', + userId: expect.any(String), + }, +}; +export const deviceDto = { + current: { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + current: true, + deviceOS: '', + deviceType: '', + }, +}; diff --git a/server/test/medium/specs/controllers/auth.controller.spec.ts b/server/test/medium/specs/controllers/auth.controller.spec.ts new file mode 100644 index 0000000000..ef2b904f48 --- /dev/null +++ b/server/test/medium/specs/controllers/auth.controller.spec.ts @@ -0,0 +1,60 @@ +import { AuthController } from 'src/controllers/auth.controller'; +import { AuthService } from 'src/services/auth.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; + +describe(AuthController.name, () => { + let app: TestControllerApp; + + beforeAll(async () => { + app = await createControllerTestApp(); + }); + + describe('POST /auth/admin-sign-up', () => { + const name = 'admin'; + const email = 'admin@immich.cloud'; + const password = 'password'; + + const invalid = [ + { + should: 'require an email address', + data: { name, password }, + }, + { + should: 'require a password', + data: { name, email }, + }, + { + should: 'require a name', + data: { email, password }, + }, + { + should: 'require a valid email', + data: { name, email: 'immich', password }, + }, + ]; + + for (const { should, data } of invalid) { + it(`should ${should}`, async () => { + const { status, body } = await request(app.getHttpServer()).post('/auth/admin-sign-up').send(data); + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + + it('should transform email to lower case', async () => { + const { status } = await request(app.getHttpServer()) + .post('/auth/admin-sign-up') + .send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' }); + expect(status).toEqual(201); + expect(app.getMockedService(AuthService).adminSignUp).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@immich.cloud' }), + ); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/server/test/medium/specs/controllers/user.controller.spec.ts b/server/test/medium/specs/controllers/user.controller.spec.ts new file mode 100644 index 0000000000..f4d90d5469 --- /dev/null +++ b/server/test/medium/specs/controllers/user.controller.spec.ts @@ -0,0 +1,100 @@ +import { UserController } from 'src/controllers/user.controller'; +import { AuthService } from 'src/services/auth.service'; +import { UserService } from 'src/services/user.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; +import { factory } from 'test/small.factory'; + +describe(UserController.name, () => { + let realApp: TestControllerApp; + let mockApp: TestControllerApp; + + beforeAll(async () => { + realApp = await createControllerTestApp({ authType: 'real' }); + mockApp = await createControllerTestApp({ authType: 'mock' }); + }); + + describe('GET /users', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/users'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should call the service with an auth dto', async () => { + const user = factory.user(); + const authService = mockApp.getMockedService(AuthService); + const auth = factory.auth({ user }); + authService.authenticate.mockResolvedValue(auth); + + const userService = mockApp.getMockedService(UserService); + const { status } = await request(mockApp.getHttpServer()).get('/users').set('Authorization', `Bearer token`); + + expect(status).toBe(200); + expect(userService.search).toHaveBeenCalledWith(auth); + }); + }); + + describe('GET /users/me', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get(`/users/me`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /users/me', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).put(`/users/me`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + for (const key of ['email', 'name']) { + it(`should not allow null ${key}`, async () => { + const dto = { [key]: null }; + const { status, body } = await request(mockApp.getHttpServer()) + .put(`/users/me`) + .set('Authorization', `Bearer token`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + }); + + describe('GET /users/:id', () => { + it('should require authentication', async () => { + const { status } = await request(realApp.getHttpServer()).get(`/users/${factory.uuid()}`); + expect(status).toEqual(401); + }); + }); + + describe('GET /server/license', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/users/me/license'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /users/me/license', () => { + it('should require authentication', async () => { + const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`); + expect(status).toEqual(401); + }); + }); + + describe('DELETE /users/me/license', () => { + it('should require authentication', async () => { + const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`); + expect(status).toEqual(401); + }); + }); + + afterAll(async () => { + await realApp.close(); + await mockApp.close(); + }); +}); diff --git a/server/test/medium/specs/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts similarity index 100% rename from server/test/medium/specs/asset.service.spec.ts rename to server/test/medium/specs/services/asset.service.spec.ts diff --git a/server/test/medium/specs/audit.database.spec.ts b/server/test/medium/specs/services/audit.database.spec.ts similarity index 100% rename from server/test/medium/specs/audit.database.spec.ts rename to server/test/medium/specs/services/audit.database.spec.ts diff --git a/server/test/medium/specs/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts similarity index 100% rename from server/test/medium/specs/memory.service.spec.ts rename to server/test/medium/specs/services/memory.service.spec.ts diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts similarity index 100% rename from server/test/medium/specs/metadata.service.spec.ts rename to server/test/medium/specs/services/metadata.service.spec.ts diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts similarity index 100% rename from server/test/medium/specs/sync.service.spec.ts rename to server/test/medium/specs/services/sync.service.spec.ts diff --git a/server/test/medium/specs/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts similarity index 92% rename from server/test/medium/specs/user.service.spec.ts rename to server/test/medium/specs/services/user.service.spec.ts index 60b5a8fc92..0113c70158 100644 --- a/server/test/medium/specs/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -60,6 +60,25 @@ describe(UserService.name, () => { }); }); + describe('search', () => { + it('should get users', async () => { + const { sut, repos } = createSut(); + const user1 = mediumFactory.userInsert(); + const user2 = mediumFactory.userInsert(); + + await Promise.all([repos.user.create(user1), repos.user.create(user2)]); + + const auth = factory.auth({ user: user1 }); + + await expect(sut.search(auth)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: user1.email }), + expect.objectContaining({ email: user2.email }), + ]), + ); + }); + }); + describe('get', () => { it('should get a user', async () => { const { sut, repos } = createSut(); diff --git a/server/test/medium/specs/version.service.spec.ts b/server/test/medium/specs/services/version.service.spec.ts similarity index 100% rename from server/test/medium/specs/version.service.spec.ts rename to server/test/medium/specs/services/version.service.spec.ts diff --git a/server/test/medium/utils.ts b/server/test/medium/utils.ts new file mode 100644 index 0000000000..030780b35b --- /dev/null +++ b/server/test/medium/utils.ts @@ -0,0 +1,100 @@ +import { Provider } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { Test } from '@nestjs/testing'; +import { ClassConstructor } from 'class-transformer'; +import { ClsService } from 'nestjs-cls'; +import { middleware } from 'src/app.module'; +import { controllers } from 'src/controllers'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { services } from 'src/services'; +import { ApiService } from 'src/services/api.service'; +import { AuthService } from 'src/services/auth.service'; +import { BaseService } from 'src/services/base.service'; +import { automock } from 'test/utils'; +import { Mocked } from 'vitest'; + +export const createControllerTestApp = async (options?: { authType?: 'mock' | 'real' }) => { + const { authType = 'mock' } = options || {}; + + const configMock = { getEnv: () => ({ noColor: true }) }; + const clsMock = { getId: vitest.fn().mockReturnValue('cls-id') }; + const loggerMock = automock(LoggingRepository, { args: [clsMock, configMock], strict: false }); + loggerMock.setContext.mockReturnValue(void 0); + loggerMock.error.mockImplementation((...args: any[]) => { + console.log('Logger.error was called with', ...args); + }); + + const mockBaseService = (service: ClassConstructor) => { + return automock(service, { args: [loggerMock], strict: false }); + }; + + const clsServiceMock = clsMock; + + const FAKE_MOCK = vitest.fn(); + + const providers: Provider[] = [ + ...middleware, + ...services.map((Service) => { + if ((authType === 'real' && Service === AuthService) || Service === ApiService) { + return Service; + } + return { provide: Service, useValue: mockBaseService(Service as ClassConstructor) }; + }), + GlobalExceptionFilter, + { provide: LoggingRepository, useValue: loggerMock }, + { provide: ClsService, useValue: clsServiceMock }, + ]; + + const moduleRef = await Test.createTestingModule({ + imports: [], + controllers: [...controllers], + providers, + }) + .useMocker((token) => { + if (token === LoggingRepository) { + return; + } + + if (token === SchedulerRegistry) { + return FAKE_MOCK; + } + + if (typeof token === 'function' && token.name.endsWith('Repository')) { + return FAKE_MOCK; + } + + if (typeof token === 'string' && token === 'KyselyModuleConnectionToken') { + return FAKE_MOCK; + } + }) + + .compile(); + + const app = moduleRef.createNestApplication(); + + await app.init(); + + const getMockedRepository = (token: ClassConstructor) => { + return app.get(token) as Mocked; + }; + + return { + getHttpServer: () => app.getHttpServer(), + getMockedService: (token: ClassConstructor) => { + if (authType === 'real' && token === AuthService) { + throw new Error('Auth type is real, cannot get mocked service'); + } + return app.get(token) as Mocked; + }, + getMockedRepository, + close: () => app.close(), + }; +}; + +export type TestControllerApp = { + getHttpServer: () => any; + getMockedService: (token: ClassConstructor) => Mocked; + getMockedRepository: (token: ClassConstructor) => Mocked; + close: () => Promise; +}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 3337106b93..94a1a28665 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -310,4 +310,5 @@ export const factory = { jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, + uuid: newUuid, }; diff --git a/server/test/utils.ts b/server/test/utils.ts index ff56a12feb..52984d97a2 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -93,13 +93,17 @@ export const automock = ( continue; } - const label = `${Dependency.name}.${property}`; - // console.log(`Automocking ${label}`); + try { + const label = `${Dependency.name}.${property}`; + // console.log(`Automocking ${label}`); - const target = instance[property as keyof T]; - if (typeof target === 'function') { - mock[property] = mockFn(label, { strict }); - continue; + const target = instance[property as keyof T]; + if (typeof target === 'function') { + mock[property] = mockFn(label, { strict }); + continue; + } + } catch { + // noop } }