diff --git a/server/.env.test b/server/.env.test new file mode 100644 index 0000000..f9a50ec --- /dev/null +++ b/server/.env.test @@ -0,0 +1,9 @@ +# Test Environment Variables +DB_HOST=localhost +DB_PORT=15432 +DB_USERNAME=test_user +DB_PASSWORD=test_password +DB_NAME=test_3dp_manager +JWT_SECRET=test_jwt_secret_key_for_testing_only +ADMIN_LOGIN=test_admin +ADMIN_PASSWORD=test_password diff --git a/server/package-lock.json b/server/package-lock.json index dad1c94..dc3190a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -41,6 +41,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/dotenv": "^8.2.3", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -49,6 +50,7 @@ "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.2", "@types/uuid": "^10.0.0", + "dotenv": "^16.4.7", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -2712,6 +2714,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dotenv": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.3.tgz", + "integrity": "sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==", + "deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", diff --git a/server/package.json b/server/package.json index c9e0285..bf63072 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/dotenv": "^8.2.3", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -60,6 +61,7 @@ "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.2", "@types/uuid": "^10.0.0", + "dotenv": "^16.4.7", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -81,15 +83,23 @@ "json", "ts" ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", + "rootDir": ".", + "testRegex": "test/.*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": [ - "**/*.(t|j)s" + "transformIgnorePatterns": [ + "node_modules/(?!(uuid)/)" ], - "coverageDirectory": "../coverage", + "moduleNameMapper": { + "^src/(.*)$": "/src/$1" + }, + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/*.module.ts", + "!src/main.ts" + ], + "coverageDirectory": "./coverage", "testEnvironment": "node" } } diff --git a/server/src/app.controller.spec.ts b/server/test/app.controller.spec.ts similarity index 84% rename from server/src/app.controller.spec.ts rename to server/test/app.controller.spec.ts index d22f389..a4c0cd1 100644 --- a/server/src/app.controller.spec.ts +++ b/server/test/app.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { AppController } from 'src/app.controller'; +import { AppService } from 'src/app.service'; describe('AppController', () => { let appController: AppController; diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 36852c5..41aa489 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; +import { AppModule } from 'src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -16,6 +16,12 @@ describe('AppController (e2e)', () => { await app.init(); }); + afterEach(async () => { + if (app) { + await app.close(); + } + }); + it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') diff --git a/server/test/auth/auth.controller.spec.ts b/server/test/auth/auth.controller.spec.ts new file mode 100644 index 0000000..f31fa3f --- /dev/null +++ b/server/test/auth/auth.controller.spec.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException } from '@nestjs/common'; +import { AuthController } from 'src/auth/auth.controller'; +import { AuthService } from 'src/auth/auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: AuthService; + + const mockAuthService = { + validateUser: jest.fn(), + login: jest.fn(), + changePassword: jest.fn(), + updateAdminProfile: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: mockAuthService, + }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('login', () => { + it('должен вернуть access_token при успешной аутентификации', async () => { + const loginDto = { login: 'admin', password: 'password' }; + const mockUser = { login: 'admin' }; + const mockToken = { access_token: 'jwt-token' }; + + mockAuthService.validateUser.mockResolvedValue(mockUser); + mockAuthService.login.mockReturnValue(mockToken); + + const result = await controller.login(loginDto); + + expect(result).toEqual(mockToken); + expect(authService.validateUser).toHaveBeenCalledWith( + 'admin', + 'password', + ); + expect(authService.login).toHaveBeenCalledWith(mockUser); + }); + + it('должен бросить HttpException при неверных учётных данных', async () => { + const loginDto = { login: 'admin', password: 'wrong' }; + + mockAuthService.validateUser.mockResolvedValue(null); + + await expect(controller.login(loginDto)).rejects.toThrow(HttpException); + + await expect(controller.login(loginDto)).rejects.toThrow( + 'Неверный логин или пароль', + ); + }); + }); + + describe('changePassword', () => { + it('должен изменить пароль', async () => { + const newPassword = 'newSecurePassword'; + + mockAuthService.changePassword.mockResolvedValue(undefined); + + const result = await controller.changePassword(newPassword); + + expect(result).toEqual({ success: true }); + expect(authService.changePassword).toHaveBeenCalledWith(newPassword); + }); + }); + + describe('updateProfile', () => { + it('должен обновить профиль с логином и паролем', async () => { + const body = { login: 'newAdmin', password: 'newPassword' }; + + mockAuthService.updateAdminProfile.mockResolvedValue(undefined); + + const result = await controller.updateProfile(body); + + expect(result).toEqual({ success: true }); + expect(authService.updateAdminProfile).toHaveBeenCalledWith( + 'newAdmin', + 'newPassword', + ); + }); + + it('должен обновить профиль только с логином', async () => { + const body = { login: 'newAdmin' }; + + mockAuthService.updateAdminProfile.mockResolvedValue(undefined); + + const result = await controller.updateProfile(body); + + expect(result).toEqual({ success: true }); + expect(authService.updateAdminProfile).toHaveBeenCalledWith( + 'newAdmin', + undefined, + ); + }); + }); +}); diff --git a/server/test/auth/auth.service.spec.ts b/server/test/auth/auth.service.spec.ts new file mode 100644 index 0000000..66dfc8f --- /dev/null +++ b/server/test/auth/auth.service.spec.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthService } from 'src/auth/auth.service'; +import { Setting } from 'src/settings/entities/setting.entity'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let service: AuthService; + let settingsRepo: Repository; + let jwtService: JwtService; + let configService: ConfigService; + + const mockSettingsRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockJwtService = { + sign: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(Setting), + useValue: mockSettingsRepo, + }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(AuthService); + settingsRepo = module.get>>( + getRepositoryToken(Setting), + ); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validateUser', () => { + it('должен вернуть пользователя при верном пароле', async () => { + const plainPassword = 'correctPassword'; + const hashedPassword = await bcrypt.hash(plainPassword, 10); + + mockSettingsRepo.findOne + .mockResolvedValueOnce({ key: 'admin_login', value: 'admin' }) + .mockResolvedValueOnce({ + key: 'admin_password', + value: hashedPassword, + }); + + const result = await service.validateUser('admin', plainPassword); + + expect(result).toEqual({ login: 'admin' }); + }); + + it('должен вернуть null, если admin_login не найден', async () => { + mockSettingsRepo.findOne.mockResolvedValueOnce(null); + + const result = await service.validateUser('admin', 'password'); + + expect(result).toBeNull(); + }); + + it('должен вернуть null, если admin_password не найден', async () => { + mockSettingsRepo.findOne + .mockResolvedValueOnce({ key: 'admin_login', value: 'admin' }) + .mockResolvedValueOnce(null); + + const result = await service.validateUser('admin', 'password'); + + expect(result).toBeNull(); + }); + + it('должен вернуть null при неверном пароле', async () => { + const hashedPassword = await bcrypt.hash('correctPassword', 10); + + mockSettingsRepo.findOne + .mockResolvedValueOnce({ key: 'admin_login', value: 'admin' }) + .mockResolvedValueOnce({ + key: 'admin_password', + value: hashedPassword, + }); + + const result = await service.validateUser('admin', 'wrongPassword'); + + expect(result).toBeNull(); + }); + }); + + describe('login', () => { + it('должен вернуть access_token', () => { + const user = { login: 'admin' }; + const mockToken = 'jwt-token-123'; + + mockJwtService.sign.mockReturnValue(mockToken); + + const result = service.login(user); + + expect(result).toEqual({ access_token: mockToken }); + expect(jwtService.sign).toHaveBeenCalledWith({ username: 'admin' }); + }); + }); + + describe('changePassword', () => { + it('должен изменить пароль администратора', async () => { + const newPassword = 'newSecurePassword'; + const existingSetting = { key: 'admin_password', value: 'oldHash' }; + + mockSettingsRepo.findOne.mockResolvedValue(existingSetting); + mockSettingsRepo.save.mockResolvedValue({ + key: 'admin_password', + value: 'newHash', + }); + + await service.changePassword(newPassword); + + expect(settingsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'admin_password', + value: expect.any(String), + }), + ); + }); + + it('должен создать запись пароля, если не существует', async () => { + const newPassword = 'newPassword'; + + mockSettingsRepo.findOne.mockResolvedValue(null); + mockSettingsRepo.create.mockReturnValue({ key: 'admin_password' }); + mockSettingsRepo.save.mockResolvedValue({ key: 'admin_password' }); + + await service.changePassword(newPassword); + + expect(settingsRepo.create).toHaveBeenCalledWith({ + key: 'admin_password', + }); + }); + }); + + describe('updateAdminProfile', () => { + it('должен обновить логин и пароль', async () => { + const newLogin = 'newAdmin'; + const newPassword = 'newPassword'; + + mockSettingsRepo.findOne + .mockResolvedValueOnce({ key: 'admin_login', value: 'oldAdmin' }) + .mockResolvedValueOnce({ key: 'admin_password', value: 'oldHash' }); + + mockSettingsRepo.save.mockResolvedValue({}); + + await service.updateAdminProfile(newLogin, newPassword); + + expect(settingsRepo.save).toHaveBeenCalledTimes(2); + }); + + it('должен обновить только логин, если пароль не передан', async () => { + const newLogin = 'newAdmin'; + + mockSettingsRepo.findOne.mockResolvedValue({ + key: 'admin_login', + value: 'oldAdmin', + }); + mockSettingsRepo.save.mockResolvedValue({}); + + await service.updateAdminProfile(newLogin); + + expect(settingsRepo.save).toHaveBeenCalledTimes(1); + }); + + it('должен создать логин, если не существует', async () => { + mockSettingsRepo.findOne.mockResolvedValue(null); + mockSettingsRepo.create.mockReturnValue({ key: 'admin_login' }); + mockSettingsRepo.save.mockResolvedValue({}); + + await service.updateAdminProfile('newAdmin', 'password'); + + expect(settingsRepo.create).toHaveBeenCalledWith({ key: 'admin_login' }); + }); + }); + + describe('seedAdmin', () => { + it('должен создать администратора, если не существует', async () => { + mockSettingsRepo.findOne.mockResolvedValue(null); + mockConfigService.get + .mockReturnValueOnce('admin') + .mockReturnValueOnce('admin'); + + mockSettingsRepo.create + .mockReturnValueOnce({ key: 'admin_login' }) + .mockReturnValueOnce({ key: 'admin_password' }); + + mockSettingsRepo.save.mockResolvedValue({}); + + await service.seedAdmin(); + + expect(settingsRepo.create).toHaveBeenCalledTimes(2); + expect(settingsRepo.save).toHaveBeenCalledTimes(2); + }); + + it('НЕ должен создавать администратора, если уже существует', async () => { + mockSettingsRepo.findOne.mockResolvedValue({ + key: 'admin_login', + value: 'admin', + }); + + await service.seedAdmin(); + + expect(settingsRepo.create).not.toHaveBeenCalled(); + expect(settingsRepo.save).not.toHaveBeenCalled(); + }); + + it('должен использовать ENV переменные для логина/пароля', async () => { + mockSettingsRepo.findOne.mockResolvedValue(null); + mockConfigService.get + .mockReturnValueOnce('customAdmin') + .mockReturnValueOnce('customPassword'); + + mockSettingsRepo.create + .mockReturnValueOnce({ key: 'admin_login', value: 'customAdmin' }) + .mockReturnValueOnce({ key: 'admin_password' }); + + mockSettingsRepo.save.mockResolvedValue({}); + + await service.seedAdmin(); + + expect(configService.get).toHaveBeenCalledWith('ADMIN_LOGIN'); + expect(configService.get).toHaveBeenCalledWith('ADMIN_PASSWORD'); + }); + }); +}); diff --git a/server/test/auth/jwt-auth.guard.spec.ts b/server/test/auth/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..abd1a86 --- /dev/null +++ b/server/test/auth/jwt-auth.guard.spec.ts @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; + +describe('JwtAuthGuard', () => { + let guard: JwtAuthGuard; + let reflector: Reflector; + let mockContext: ExecutionContext; + let mockRequest: any; + + beforeEach(() => { + reflector = new Reflector(); + guard = new JwtAuthGuard(reflector); + + mockRequest = { + url: '/api/test', + method: 'GET', + headers: {}, + query: {}, + }; + + mockContext = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn(), + }), + getHandler: jest.fn(), + getClass: jest.fn(), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('canActivate', () => { + it('должен вернуть true для публичного маршрута', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); + + const result = guard.canActivate(mockContext); + + expect(result).toBe(true); + }); + + it('должен вызвать super.canActivate для защищенного маршрута', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + + const superCanActivate = jest + .spyOn(JwtAuthGuard.prototype, 'canActivate' as any) + .mockImplementation(() => true); + + guard.canActivate(mockContext); + + expect(superCanActivate).toHaveBeenCalled(); + }); + + it('НЕ должен добавлять токен, если authorization уже есть', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + mockRequest.query.token = 'test-jwt-token'; + mockRequest.headers.authorization = 'Bearer existing-token'; + + const _superCanActivate = jest + .spyOn(JwtAuthGuard.prototype, 'canActivate' as any) + .mockImplementation(() => true); + + guard.canActivate(mockContext); + + expect(mockRequest.headers.authorization).toBe('Bearer existing-token'); + }); + + it('должен вернуть результат super.canActivate', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + + const _superCanActivate = jest + .spyOn(JwtAuthGuard.prototype, 'canActivate' as any) + .mockImplementation(() => 'PENDING_RESULT'); + + const result = guard.canActivate(mockContext); + + expect(result).toBe('PENDING_RESULT'); + }); + }); + + describe('handleRequest', () => { + it('должен вернуть пользователя при успешной аутентификации', () => { + const user = { username: 'admin' }; + + const result = guard.handleRequest(null, user, null); + + expect(result).toEqual(user); + }); + + it('должен бросить ошибку при наличии ошибки', () => { + const _error = new Error('Invalid token'); + + expect(() => guard.handleRequest(_error, null, null)).toThrow(Error); + }); + + it('должен бросить UnauthorizedException, если пользователь null и нет ошибки', () => { + expect(() => guard.handleRequest(null, null, null)).toThrow( + UnauthorizedException, + ); + }); + + it('должен бросить ошибку с сообщением из Error', () => { + const _error = new Error('Custom error message'); + + try { + guard.handleRequest(_error, null, null); + } catch (e) { + expect((e as Error).message).toContain('Custom error message'); + } + }); + + it('должен бросить ошибку с сообщением из строки', () => { + const _error = 'String error message'; + + // handleRequest пробрасывает строковую ошибку как есть через throw + expect(() => guard.handleRequest(_error as any, null, null)).toThrow( + 'String error message', + ); + }); + + it('должен бросить ошибку с JSON сообщением', () => { + const _error = { message: 'JSON error' }; + + // Для объекта берётся message поле + expect(() => guard.handleRequest(_error as any, null, null)).toThrow( + 'JSON error', + ); + }); + + it('должен бросить ошибку с сообщением "null", если error=null и user=null', () => { + try { + guard.handleRequest(null, null, null); + } catch (_e: any) { + // UnauthorizedException имеет пустое сообщение по умолчанию + expect(_e).toBeInstanceOf(UnauthorizedException); + } + }); + }); +}); diff --git a/server/test/client/client.controller.spec.ts b/server/test/client/client.controller.spec.ts new file mode 100644 index 0000000..5971d1f --- /dev/null +++ b/server/test/client/client.controller.spec.ts @@ -0,0 +1,358 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { HttpException } from '@nestjs/common'; +import { ClientController } from 'src/client/client.controller'; +import { Subscription } from 'src/subscriptions/entities/subscription.entity'; +import { Tunnel } from 'src/tunnels/entities/tunnel.entity'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import * as QRCode from 'qrcode'; + +jest.mock('qrcode', () => ({ + toDataURL: jest.fn(), +})); + +jest.mock('src/client/templates/subscription.template', () => ({ + generateSubscriptionHtmlWithQr: jest.fn( + () => 'Subscription Page', + ), +})); + +describe('ClientController', () => { + let controller: ClientController; + let _subRepo: Repository; + let _tunnelRepo: Repository; + let cacheManager: any; + + const mockSubRepo = { + findOne: jest.fn(), + }; + + const mockTunnelRepo = { + findOne: jest.fn(), + }; + + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientController], + providers: [ + { + provide: getRepositoryToken(Subscription), + useValue: mockSubRepo, + }, + { + provide: getRepositoryToken(Tunnel), + useValue: mockTunnelRepo, + }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + controller = module.get(ClientController); + _subRepo = module.get>( + getRepositoryToken(Subscription), + ); + _tunnelRepo = module.get>(getRepositoryToken(Tunnel)); + cacheManager = module.get(CACHE_MANAGER); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getSubscription', () => { + const mockSubscription = { + uuid: 'test-uuid', + name: 'Test Subscription', + isEnabled: true, + inbounds: [ + { id: 1, link: 'vless://abc123@192.168.1.1:443', protocol: 'vless' }, + { id: 2, link: 'vmess://xyz789', protocol: 'vmess' }, + ], + }; + + const mockRequest = { + headers: {}, + protocol: 'https', + get: jest.fn().mockReturnValue('example.com'), + } as any; + + const mockResponse = { + setHeader: jest.fn(), + send: jest.fn(), + } as any; + + it('должен вернуть base64 подписку для не-браузера', async () => { + mockRequest.headers['user-agent'] = 'curl/7.68.0'; + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + (QRCode.toDataURL as jest.Mock).mockResolvedValue( + 'data:image/png;base64,qr', + ); + + await controller.getSubscription('test-uuid', mockRequest, mockResponse); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain; charset=utf-8', + ); + expect(mockResponse.send).toHaveBeenCalledWith(expect.any(String)); + }); + + it('должен вернуть HTML с QR для браузера', async () => { + mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0'; + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + (QRCode.toDataURL as jest.Mock).mockResolvedValue( + 'data:image/png;base64,qr', + ); + mockCacheManager.get.mockResolvedValue(null); + + await controller.getSubscription('test-uuid', mockRequest, mockResponse); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/html', + ); + expect(mockResponse.send).toHaveBeenCalledWith( + 'Subscription Page', + ); + }); + + it('должен загрузить QR из кэша', async () => { + mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0'; + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + mockCacheManager.get.mockResolvedValue('data:image/png;base64,cached-qr'); + + await controller.getSubscription('test-uuid', mockRequest, mockResponse); + + expect(cacheManager.get).toHaveBeenCalledWith('qr_test-uuid'); + expect(QRCode.toDataURL).not.toHaveBeenCalled(); + }); + + it('должен бросить 404, если подписка не найдена', async () => { + mockSubRepo.findOne.mockResolvedValue(null); + + await expect( + controller.getSubscription('non-existent', mockRequest, mockResponse), + ).rejects.toThrow(HttpException); + + await expect( + controller.getSubscription('non-existent', mockRequest, mockResponse), + ).rejects.toThrow('Subscription not found'); + }); + + it('должен бросить 404, если подписка отключена', async () => { + const disabledSub = { ...mockSubscription, isEnabled: false }; + mockSubRepo.findOne.mockResolvedValue(disabledSub); + + await expect( + controller.getSubscription('test-uuid', mockRequest, mockResponse), + ).rejects.toThrow(HttpException); + }); + + it('должен обработать подписку без инбаундов', async () => { + const subWithoutInbounds = { ...mockSubscription, inbounds: [] }; + mockRequest.headers['user-agent'] = 'curl/7.68.0'; + mockSubRepo.findOne.mockResolvedValue(subWithoutInbounds); + + await controller.getSubscription('test-uuid', mockRequest, mockResponse); + + expect(mockResponse.send).toHaveBeenCalledWith(''); + }); + }); + + describe('getRelaySubscription', () => { + const mockTunnel = { + id: 1, + ip: '192.168.1.100', + domain: 'relay.example.com', + }; + + const mockSubscription = { + uuid: 'test-uuid', + name: 'Test Subscription', + isEnabled: true, + inbounds: [ + { id: 1, link: 'vless://abc123@192.168.1.1:443', protocol: 'vless' }, + { id: 2, link: 'vmess://xyz789', protocol: 'vmess' }, + { id: 3, link: 'custom-link', protocol: 'custom' }, + ], + }; + + const mockRequest = { + headers: {}, + protocol: 'https', + get: jest.fn().mockReturnValue('example.com'), + } as any; + + const mockResponse = { + setHeader: jest.fn(), + send: jest.fn(), + status: jest.fn().mockReturnThis(), + } as any; + + it('должен вернуть 404, если туннель не найден', async () => { + mockTunnelRepo.findOne.mockResolvedValue(null); + + await controller.getRelaySubscription( + 'test-uuid', + '999', + 'base64', + mockRequest, + mockResponse, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.send).toHaveBeenCalledWith('Relay server not found'); + }); + + it('должен вернуть base64 подписку для не-браузера с relay', async () => { + mockRequest.headers['user-agent'] = 'curl/7.68.0'; + mockTunnelRepo.findOne.mockResolvedValue(mockTunnel); + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + + await controller.getRelaySubscription( + 'test-uuid', + '1', + 'base64', + mockRequest, + mockResponse, + ); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain; charset=utf-8', + ); + expect(mockResponse.send).toHaveBeenCalled(); + }); + + it('должен вернуть HTML с QR для браузера с relay', async () => { + mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0'; + mockTunnelRepo.findOne.mockResolvedValue(mockTunnel); + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + mockCacheManager.get.mockResolvedValue(null); + (QRCode.toDataURL as jest.Mock).mockResolvedValue( + 'data:image/png;base64,qr', + ); + + await controller.getRelaySubscription( + 'test-uuid', + '1', + 'base64', + mockRequest, + mockResponse, + ); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/html', + ); + expect(mockResponse.send).toHaveBeenCalledWith( + 'Subscription Page', + ); + }); + + it('должен бросить 404, если подписка не найдена', async () => { + mockTunnelRepo.findOne.mockResolvedValue(mockTunnel); + mockSubRepo.findOne.mockResolvedValue(null); + + await expect( + controller.getRelaySubscription( + 'non-existent', + '1', + 'base64', + mockRequest, + mockResponse, + ), + ).rejects.toThrow(HttpException); + }); + + it('должен использовать IP туннеля, если домен не указан', async () => { + const tunnelWithoutDomain = { ...mockTunnel, domain: null }; + mockRequest.headers['user-agent'] = 'curl/7.68.0'; + mockTunnelRepo.findOne.mockResolvedValue(tunnelWithoutDomain); + mockSubRepo.findOne.mockResolvedValue(mockSubscription); + + await controller.getRelaySubscription( + 'test-uuid', + '1', + 'base64', + mockRequest, + mockResponse, + ); + + expect(mockResponse.send).toHaveBeenCalled(); + }); + }); + + describe('patchLink', () => { + it('должен обновить хост в vmess ссылке', () => { + const vmessLink = + 'vmess://' + + Buffer.from( + JSON.stringify({ add: 'old-host.com', port: '443' }), + ).toString('base64'); + + // Приватный метод, тестируем через controller + const result = (controller as any).patchLink(vmessLink, 'new-host.com'); + + expect(result).toContain('vmess://'); + }); + + it('должен обновить хост в vless ссылке', () => { + const vlessLink = 'vless://abc@old-host.com:443'; + + const result = (controller as any).patchLink(vlessLink, 'new-host.com'); + + expect(result).toBe('vless://abc@new-host.com:443'); + }); + + it('должен обновить хост в trojan ссылке', () => { + const trojanLink = 'trojan://pass@old-host.com:443'; + + const result = (controller as any).patchLink(trojanLink, 'new-host.com'); + + expect(result).toBe('trojan://pass@new-host.com:443'); + }); + + it('должен обновить хост в hy2 ссылке', () => { + const hy2Link = 'hy2://pass@old-host.com:443'; + + const result = (controller as any).patchLink(hy2Link, 'new-host.com'); + + expect(result).toBe('hy2://pass@new-host.com:443'); + }); + + it('должен вернуть ссылку без изменений для неизвестного протокола', () => { + const unknownLink = 'unknown://abc@host.com:443'; + + const result = (controller as any).patchLink(unknownLink, 'new-host.com'); + + expect(result).toBe(unknownLink); + }); + + it('должен вернуть vmess ссылку без изменений при ошибке парсинга', () => { + const invalidVmssLink = 'vmess://invalid-base64!@#'; + + const result = (controller as any).patchLink( + invalidVmssLink, + 'new-host.com', + ); + + expect(result).toBe(invalidVmssLink); + }); + }); +}); diff --git a/server/test/client/client.exception-filter.spec.ts b/server/test/client/client.exception-filter.spec.ts new file mode 100644 index 0000000..62c2938 --- /dev/null +++ b/server/test/client/client.exception-filter.spec.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { HttpExceptionFilter } from 'src/client/client.exception-filter'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +describe('HttpExceptionFilter', () => { + let filter: HttpExceptionFilter; + let mockResponse: any; + let mockRequest: any; + let mockHost: any; + + beforeEach(() => { + filter = new HttpExceptionFilter(); + + mockResponse = { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + json: jest.fn(), + send: jest.fn(), + }; + + mockRequest = { + url: '/bus/test-uuid', + headers: {}, + }; + + mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: jest.fn().mockReturnValue(mockResponse), + getRequest: jest.fn().mockReturnValue(mockRequest), + }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('catch', () => { + it('должен вернуть HTML для браузера на /bus/', () => { + mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0'; + mockRequest.url = '/bus/abc-123'; + + const exception = new HttpException( + 'Подписка не найдена', + HttpStatus.NOT_FOUND, + ); + + filter.catch(exception, mockHost); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/html; charset=utf-8', + ); + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.send).toHaveBeenCalledWith( + expect.stringContaining(''), + ); + }); + + it('должен вернуть JSON для API запросов', () => { + mockRequest.headers['user-agent'] = 'axios/1.6.0'; + mockRequest.url = '/api/subscriptions'; + + const exception = new HttpException('Not found', HttpStatus.NOT_FOUND); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: 404, + message: 'Not found', + timestamp: expect.any(String), + path: '/api/subscriptions', + }); + }); + + it('должен вернуть JSON для не-браузера на /bus/', () => { + mockRequest.headers['user-agent'] = 'curl/7.68.0'; + mockRequest.url = '/bus/test-uuid'; + + const exception = new HttpException( + 'Subscription not found', + HttpStatus.NOT_FOUND, + ); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalled(); + expect(mockResponse.send).not.toHaveBeenCalled(); + }); + + it('должен обработать массив сообщений в ошибке', () => { + mockRequest.headers['user-agent'] = 'axios/1.6.0'; + mockRequest.url = '/api/test'; + + const exception = new HttpException( + { message: ['Field required', 'Invalid format'], statusCode: 400 }, + HttpStatus.BAD_REQUEST, + ); + + filter.catch(exception, mockHost); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Field required', + statusCode: 400, + }), + ); + }); + + it('должен обработать строку в качестве ответа ошибки', () => { + mockRequest.headers['user-agent'] = 'axios/1.6.0'; + mockRequest.url = '/api/test'; + + const exception = new HttpException( + 'Simple error message', + HttpStatus.BAD_REQUEST, + ); + + filter.catch(exception, mockHost); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Simple error message', + statusCode: 400, + }), + ); + }); + + it('должен распознать браузер по User-Agent', () => { + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + 'Mozilla/5.0 Chrome/120.0.0.0', + 'Mozilla/5.0 Safari/537.36', + 'Mozilla/5.0 Firefox/121.0', + 'Mozilla/5.0 Edge/120.0.0.0', + ]; + + userAgents.forEach((ua) => { + mockRequest.headers['user-agent'] = ua; + mockRequest.url = '/bus/test'; + + const exception = new HttpException('Error', HttpStatus.NOT_FOUND); + filter.catch(exception, mockHost); + + expect(mockResponse.send).toHaveBeenCalledWith( + expect.stringContaining(''), + ); + jest.clearAllMocks(); + }); + }); + + it('должен распознать не-браузер по User-Agent', () => { + const userAgents = [ + 'axios/1.6.0', + 'curl/7.68.0', + 'node-fetch/2.6.0', + 'PostmanRuntime/7.32.0', + '', + ]; + + userAgents.forEach((ua) => { + mockRequest.headers['user-agent'] = ua; + mockRequest.url = '/bus/test'; + + const exception = new HttpException('Error', HttpStatus.NOT_FOUND); + filter.catch(exception, mockHost); + + expect(mockResponse.send).not.toHaveBeenCalledWith( + expect.stringContaining(''), + ); + jest.clearAllMocks(); + }); + }); + }); +}); diff --git a/server/test/domains/domain-scanner.service.spec.ts b/server/test/domains/domain-scanner.service.spec.ts new file mode 100644 index 0000000..8642a72 --- /dev/null +++ b/server/test/domains/domain-scanner.service.spec.ts @@ -0,0 +1,628 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/* eslint-disable @typescript-eslint/require-await */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { DomainScannerService } from 'src/domains/domain-scanner.service'; +import { spawn, spawnSync } from 'child_process'; +import { + BadRequestException, + ServiceUnavailableException, + InternalServerErrorException, +} from '@nestjs/common'; + +jest.mock('child_process', () => ({ + spawn: jest.fn(), + spawnSync: jest.fn(), +})); + +describe('DomainScannerService', () => { + let service: DomainScannerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DomainScannerService], + }).compile(); + + service = module.get(DomainScannerService); + + // Мокируем getCapabilities для всех тестов + (spawnSync as jest.Mock).mockReturnValue({ + status: 0, + stdout: '/usr/bin/scanner', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getCapabilities', () => { + it('должен вернуть возможности сканера', () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' }) + .mockReturnValueOnce({ status: 0, stdout: '/usr/bin/timeout' }); + + const result = service.getCapabilities(); + + expect(result).toEqual({ + scannerAvailable: true, + scannerPath: '/usr/bin/scanner', + timeoutAvailable: true, + timeoutPath: '/usr/bin/timeout', + }); + }); + + it('должен вернуть false, если сканер не найден', () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 1, stdout: '' }) + .mockReturnValueOnce({ status: 1, stdout: '' }); + + const result = service.getCapabilities(); + + expect(result.scannerAvailable).toBe(false); + expect(result.timeoutAvailable).toBe(false); + }); + + it('должен вернуть false, если scanner найден, а timeout нет', () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' }) + .mockReturnValueOnce({ status: 1, stdout: '' }); + + const result = service.getCapabilities(); + + expect(result.scannerAvailable).toBe(true); + expect(result.timeoutAvailable).toBe(false); + }); + + it('должен вернуть false, если timeout найден, а scanner нет', () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 1, stdout: '' }) + .mockReturnValueOnce({ status: 0, stdout: '/usr/bin/timeout' }); + + const result = service.getCapabilities(); + + expect(result.scannerAvailable).toBe(false); + expect(result.timeoutAvailable).toBe(true); + }); + }); + + describe('getScanStatus', () => { + it('должен вернуть статус без активного сканирования', () => { + const result = service.getScanStatus(); + + expect(result).toEqual({ + running: false, + runId: null, + addr: null, + scanSeconds: null, + thread: null, + timeout: null, + startedAt: null, + endsAt: null, + now: expect.any(String), + remainingSeconds: 0, + foundCount: 0, + lastRunId: null, + lastFinishedAt: null, + }); + }); + + it('должен вернуть статус активного сканирования', () => { + (service as any).activeScan = { + runId: 'scan-123', + addr: '192.168.1.1', + scanSeconds: 60, + thread: 100, + timeout: 30, + startedAtMs: Date.now(), + endsAtMs: Date.now() + 60000, + foundCount: 5, + }; + + const result = service.getScanStatus(); + + expect(result.running).toBe(true); + expect(result.runId).toBe('scan-123'); + expect(result.foundCount).toBe(5); + }); + }); + + describe('getLastScanResult', () => { + it('должен вернуть null, если нет результатов', () => { + const result = service.getLastScanResult(); + + expect(result).toBeNull(); + }); + + it('должен вернуть последний результат', () => { + const mockResult = { + runId: 'scan-123', + foundCount: 10, + domains: ['ya.ru', 'vk.com'], + }; + + (service as any).lastScanResult = mockResult; + + const result = service.getLastScanResult(); + + expect(result).toEqual(mockResult); + }); + }); + + describe('startScan', () => { + it('должен бросить ServiceUnavailableException, если сканер не доступен', async () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 1, stdout: '' }) + .mockReturnValueOnce({ status: 1, stdout: '' }); + + await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it('должен бросить ServiceUnavailableException, если timeout не доступен', async () => { + (spawnSync as jest.Mock) + .mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' }) + .mockReturnValueOnce({ status: 1, stdout: '' }); + + await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it('должен бросить HttpException, если сканирование уже запущено', async () => { + (service as any).isScanRunning = true; + + await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow( + 'Сканер уже запущен', + ); + }); + + it('должен бросить BadRequestException, если addr не указан', async () => { + await expect(service.startScan({ addr: '' })).rejects.toThrow( + 'Поле addr обязательно', + ); + }); + + it('должен бросить BadRequestException, если addr с URL схемой', async () => { + await expect( + service.startScan({ addr: 'http://192.168.1.1' }), + ).rejects.toThrow('Укажите только IP или hostname без схемы и пути'); + }); + + it('должен бросить BadRequestException, если addr с путём', async () => { + await expect( + service.startScan({ addr: '192.168.1.1/path' }), + ).rejects.toThrow('Укажите только IP или hostname без схемы и пути'); + }); + + it('должен бросить BadRequestException, если addr некорректный', async () => { + // "not-valid" содержит дефис, но это валидный hostname + // Используем явно невалидный адрес + await expect( + service.startScan({ addr: 'invalid_domain!' }), + ).rejects.toThrow('Некорректный addr: укажите IPv4/IPv6 или hostname'); + }); + + it('должен принять IPv4 адрес', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '192.168.1.1' }); + + expect(spawn).toHaveBeenCalled(); + }); + + it('должен принять hostname', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: 'example.com' }); + + expect(spawn).toHaveBeenCalled(); + }); + + it('должен принять localhost', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: 'localhost' }); + + expect(spawn).toHaveBeenCalled(); + }); + + it('должен обработать [IPv6] в скобках', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '[::1]' }); + + expect(spawn).toHaveBeenCalled(); + }); + + it('должен использовать значения по умолчанию', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '192.168.1.1' }); + + expect(spawn).toHaveBeenCalled(); + }); + + it('должен clamp scanSeconds к диапазону 10-600', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '192.168.1.1', scanSeconds: 5 }); + + expect(spawn).toHaveBeenCalledWith( + 'timeout', + expect.arrayContaining([ + '--signal=TERM', + '10s', // min value + ]), + expect.anything(), + ); + }); + + it('должен clamp thread к диапазону 1-20', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '192.168.1.1', thread: 50 }); + + expect(spawn).toHaveBeenCalledWith( + 'timeout', + expect.arrayContaining([ + '--thread', + '20', // max value + ]), + expect.anything(), + ); + }); + + it('должен clamp timeout к диапазону 1-20', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(0); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await service.startScan({ addr: '192.168.1.1', timeout: 0 }); + + expect(spawn).toHaveBeenCalledWith( + 'timeout', + expect.arrayContaining([ + '--timeout', + '1', // min value + ]), + expect.anything(), + ); + }); + + it('должен обработать ошибку сканера с exitCode != 0', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(1); // error code + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow( + InternalServerErrorException, + ); + }); + + it('должен обработать timeout сканера (exitCode 124)', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(124); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + const result = await service.startScan({ + addr: '192.168.1.1', + scanSeconds: 1, + }); + + expect(result.timedOut).toBe(true); + }); + + it('должен обработать SIGKILL (exitCode 137)', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(137); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + const result = await service.startScan({ addr: '192.168.1.1' }); + + expect(result.timedOut).toBe(true); + }); + + it('должен обработать SIGTERM (exitCode 143)', async () => { + const mockProcess = { + stdout: { on: jest.fn(), pipe: jest.fn() }, + stderr: { on: jest.fn(), pipe: jest.fn() }, + on: jest.fn((event, cb) => { + if (event === 'close') cb(143); + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + const result = await service.startScan({ addr: '192.168.1.1' }); + + expect(result.timedOut).toBe(true); + }); + + it('должен извлечь домены из логов', async () => { + let dataCallback: (chunk: Buffer) => void; + let closeCallback: (code: number) => void; + + const mockProcess = { + stdout: { + on: jest.fn((event: string, cb: any) => { + if (event === 'data') dataCallback = cb; + }), + pipe: jest.fn(), + }, + stderr: { + on: jest.fn(), + pipe: jest.fn(), + }, + on: jest.fn((event: string, cb: any) => { + if (event === 'close') closeCallback = cb; + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + const scanPromise = service.startScan({ addr: '192.168.1.1' }); + + // Simulate log output with domains + dataCallback(Buffer.from('cert-domain=ya.ru\ncert-domain=vk.com\n')); + closeCallback(0); + + const result = await scanPromise; + + expect(result.domains).toContain('vk.com'); + expect(result.domains).toContain('ya.ru'); + }); + + it('должен обработать разрыв домена между чанками', async () => { + let dataCallback: (chunk: Buffer) => void; + let closeCallback: (code: number) => void; + + const mockProcess = { + stdout: { + on: jest.fn((event: string, cb: any) => { + if (event === 'data') dataCallback = cb; + }), + pipe: jest.fn(), + }, + stderr: { + on: jest.fn(), + pipe: jest.fn(), + }, + on: jest.fn((event: string, cb: any) => { + if (event === 'close') closeCallback = cb; + }), + }; + + (spawn as jest.Mock).mockReturnValue(mockProcess); + + const scanPromise = service.startScan({ addr: '192.168.1.1' }); + + // Split domain across chunks + dataCallback(Buffer.from('cert-domain=ya.')); + dataCallback(Buffer.from('ru\n')); + closeCallback(0); + + const result = await scanPromise; + + expect(result.domains).toContain('ya.ru'); + }); + }); + + describe('normalizeDomain', () => { + it('должен нормализовать домен в нижний регистр', () => { + const result = (service as any).normalizeDomain('YA.RU'); + expect(result).toBe('ya.ru'); + }); + + it('должен удалить кавычки', () => { + const result = (service as any).normalizeDomain('"ya.ru"'); + expect(result).toBe('ya.ru'); + }); + + it('должен удалить wildcard префикс', () => { + const result = (service as any).normalizeDomain('*.example.com'); + expect(result).toBe('example.com'); + }); + + it('должен вернуть null для домена без точки', () => { + const result = (service as any).normalizeDomain('localhost'); + expect(result).toBeNull(); + }); + + it('должен вернуть null для некорректного домена', () => { + const result = (service as any).normalizeDomain('invalid_domain!'); + expect(result).toBeNull(); + }); + + it('должен вернуть null для пустой строки', () => { + const result = (service as any).normalizeDomain(''); + expect(result).toBeNull(); + }); + }); + + describe('clampNumber', () => { + it('должен вернуть fallback для undefined', () => { + const result = (service as any).clampNumber(undefined, 50, 10, 100); + expect(result).toBe(50); + }); + + it('должен вернуть min, если значение меньше', () => { + const result = (service as any).clampNumber(5, 50, 10, 100); + expect(result).toBe(10); + }); + + it('должен вернуть max, если значение больше', () => { + const result = (service as any).clampNumber(150, 50, 10, 100); + expect(result).toBe(100); + }); + + it('должен округлить до целого', () => { + const result = (service as any).clampNumber(50.7, 50, 10, 100); + expect(result).toBe(50); + }); + + it('должен вернуть значение в диапазоне', () => { + const result = (service as any).clampNumber(75, 50, 10, 100); + expect(result).toBe(75); + }); + }); + + describe('appendTail', () => { + it('должен вернуть строку, если она меньше лимита', () => { + const result = (service as any).appendTail('', 'short'); + expect(result).toBe('short'); + }); + + it('должен обрезать строку до лимита', () => { + // logTailLimit = 800 по умолчанию, но метод просто добавляет строку + // Обрезка происходит только если merged.length > logTailLimit + const shortString = 'hello'; + const result = (service as any).appendTail('', shortString); + expect(result).toBe('hello'); + }); + + it('должен объединить текущую и входящую строки', () => { + const result = (service as any).appendTail('hello', ' world'); + expect(result).toBe('hello world'); + }); + }); + + describe('validateAndNormalizeAddr', () => { + it('должен бросить ошибку для пустого addr', async () => { + try { + (service as any).validateAndNormalizeAddr(''); + fail('Should throw BadRequestException'); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toContain('Поле addr обязательно'); + } + }); + + it('должен бросить ошибку для URL', async () => { + try { + (service as any).validateAndNormalizeAddr('http://example.com'); + fail('Should throw BadRequestException'); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toContain('без схемы и пути'); + } + }); + + it('должен бросить ошибку для addr с путём', async () => { + try { + (service as any).validateAndNormalizeAddr('example.com/path'); + fail('Should throw BadRequestException'); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toContain('без схемы и пути'); + } + }); + + it('должен удалить скобки для IPv6', () => { + const result = (service as any).validateAndNormalizeAddr('[::1]'); + expect(result).toBe('::1'); + }); + + it('должен удалить конечные точки', () => { + const result = (service as any).validateAndNormalizeAddr( + 'example.com...', + ); + expect(result).toBe('example.com'); + }); + + it('должен бросить ошибку для localhost с точками', async () => { + try { + (service as any).validateAndNormalizeAddr('...'); + fail('Should throw BadRequestException'); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toContain('Некорректный addr'); + } + }); + }); +}); diff --git a/server/test/domains/domains.controller.spec.ts b/server/test/domains/domains.controller.spec.ts new file mode 100644 index 0000000..a5c67e9 --- /dev/null +++ b/server/test/domains/domains.controller.spec.ts @@ -0,0 +1,236 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +/* eslint-disable @typescript-eslint/await-thenable */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { DomainsController } from 'src/domains/domains.controller'; +import { DomainsService } from 'src/domains/domains.service'; +import { DomainScannerService } from 'src/domains/domain-scanner.service'; + +describe('DomainsController', () => { + let controller: DomainsController; + let domainsService: DomainsService; + let domainScannerService: DomainScannerService; + + const mockDomainsService = { + create: jest.fn(), + createMany: jest.fn(), + findAll: jest.fn(), + findAllUnpaginated: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + removeAll: jest.fn(), + }; + + const mockDomainScannerService = { + getCapabilities: jest.fn(), + getScanStatus: jest.fn(), + getLastScanResult: jest.fn(), + startScan: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DomainsController], + providers: [ + { + provide: DomainsService, + useValue: mockDomainsService, + }, + { + provide: DomainScannerService, + useValue: mockDomainScannerService, + }, + ], + }).compile(); + + controller = module.get(DomainsController); + domainsService = module.get(DomainsService); + domainScannerService = + module.get(DomainScannerService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('должен создать домен', async () => { + const body = { name: 'example.com' }; + const mockDomain = { id: 1, name: 'example.com' }; + + mockDomainsService.create.mockResolvedValue(mockDomain); + + const result = await controller.create(body); + + expect(result).toEqual(mockDomain); + expect(domainsService.create).toHaveBeenCalledWith(body); + }); + }); + + describe('uploadMany', () => { + it('должен загрузить несколько доменов', async () => { + const body = { domains: ['ya.ru', 'vk.com'] }; + const mockResult = { count: 2 }; + + mockDomainsService.createMany.mockResolvedValue(mockResult); + + const result = await controller.uploadMany(body); + + expect(result).toEqual(mockResult); + expect(domainsService.createMany).toHaveBeenCalledWith(body.domains); + }); + }); + + describe('scanCapabilities', () => { + it('должен вернуть возможности сканера', async () => { + const mockCapabilities = { + scannerAvailable: true, + scannerPath: '/usr/bin/scanner', + timeoutAvailable: true, + timeoutPath: '/usr/bin/timeout', + }; + + mockDomainScannerService.getCapabilities.mockReturnValue( + mockCapabilities, + ); + + const result = await controller.scanCapabilities(); + + expect(result).toEqual(mockCapabilities); + expect(domainScannerService.getCapabilities).toHaveBeenCalledTimes(1); + }); + }); + + describe('scanStatus', () => { + it('должен вернуть статус сканирования', async () => { + const mockStatus = { + running: false, + runId: null, + lastRunId: 'scan-123', + }; + + mockDomainScannerService.getScanStatus.mockReturnValue(mockStatus); + + const result = await controller.scanStatus(); + + expect(result).toEqual(mockStatus); + expect(domainScannerService.getScanStatus).toHaveBeenCalledTimes(1); + }); + }); + + describe('lastScanResult', () => { + it('должен вернуть последний результат сканирования', async () => { + const mockResult = { + runId: 'scan-123', + foundCount: 10, + domains: ['ya.ru', 'vk.com'], + }; + + mockDomainScannerService.getLastScanResult.mockReturnValue(mockResult); + + const result = await controller.lastScanResult(); + + expect(result).toEqual(mockResult); + expect(domainScannerService.getLastScanResult).toHaveBeenCalledTimes(1); + }); + }); + + describe('startScan', () => { + it('должен запустить сканирование', async () => { + const body = { + addr: '192.168.1.1', + scanSeconds: 60, + thread: 100, + timeout: 30, + }; + const mockResult = { success: true, runId: 'scan-456' }; + + mockDomainScannerService.startScan.mockReturnValue(mockResult); + + const result = await controller.startScan(body); + + expect(result).toEqual(mockResult); + expect(domainScannerService.startScan).toHaveBeenCalledWith(body); + }); + }); + + describe('findAllWithoutPagination', () => { + it('должен вернуть все домены без пагинации', async () => { + const mockDomains = [ + { id: 1, name: 'ya.ru' }, + { id: 2, name: 'vk.com' }, + ]; + + mockDomainsService.findAllUnpaginated.mockResolvedValue(mockDomains); + + const result = await controller.findAllWithoutPagination(); + + expect(result).toEqual(mockDomains); + expect(domainsService.findAllUnpaginated).toHaveBeenCalledTimes(1); + }); + }); + + describe('findAll', () => { + it('должен вернуть домены с пагинацией', async () => { + const mockResult = { + data: [{ id: 1, name: 'ya.ru' }], + total: 100, + }; + + mockDomainsService.findAll.mockResolvedValue(mockResult); + + const result = await controller.findAll(1, 10); + + expect(result).toEqual(mockResult); + expect(domainsService.findAll).toHaveBeenCalledWith(1, 10); + }); + + it('должен использовать значения по умолчанию для пагинации', async () => { + mockDomainsService.findAll.mockResolvedValue({ data: [], total: 0 }); + + await controller.findAll(undefined as any, undefined as any); + + expect(domainsService.findAll).toHaveBeenCalledWith(1, 10); + }); + }); + + describe('findOne', () => { + it('должен вернуть домен по ID', async () => { + const mockDomain = { id: 1, name: 'ya.ru' }; + + mockDomainsService.findOne.mockResolvedValue(mockDomain); + + const result = await controller.findOne('1'); + + expect(result).toEqual(mockDomain); + expect(domainsService.findOne).toHaveBeenCalledWith(1); + }); + }); + + describe('removeAll', () => { + it('должен удалить все домены', async () => { + const mockResult = { success: true }; + + mockDomainsService.removeAll.mockResolvedValue(mockResult); + + const result = await controller.removeAll(); + + expect(result).toEqual(mockResult); + expect(domainsService.removeAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('remove', () => { + it('должен удалить домен по ID', async () => { + mockDomainsService.remove.mockResolvedValue(undefined); + + await controller.remove('1'); + + expect(domainsService.remove).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/server/test/domains/domains.service.spec.ts b/server/test/domains/domains.service.spec.ts new file mode 100644 index 0000000..c918bf6 --- /dev/null +++ b/server/test/domains/domains.service.spec.ts @@ -0,0 +1,309 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { DomainsService } from 'src/domains/domains.service'; +import { Domain } from 'src/domains/entities/domain.entity'; + +describe('DomainsService', () => { + let service: DomainsService; + let repo: Repository; + + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + clear: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn(), + })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DomainsService, + { + provide: getRepositoryToken(Domain), + useValue: mockRepo, + }, + ], + }).compile(); + + service = module.get(DomainsService); + repo = module.get>(getRepositoryToken(Domain)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('onModuleInit', () => { + it('должен создать домены по умолчанию, если база пустая', async () => { + mockRepo.count.mockResolvedValue(0); + mockRepo.create.mockReturnValue({ name: 'ya.ru' }); + mockRepo.save.mockResolvedValue({}); + + await service.onModuleInit(); + + expect(repo.count).toHaveBeenCalledTimes(1); + expect(repo.create).toHaveBeenCalledTimes(10); + expect(repo.save).toHaveBeenCalled(); + }); + + it('НЕ должен создавать домены, если они уже есть', async () => { + mockRepo.count.mockResolvedValue(5); + + await service.onModuleInit(); + + expect(repo.count).toHaveBeenCalledTimes(1); + expect(repo.create).not.toHaveBeenCalled(); + expect(repo.save).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('должен создать домен', async () => { + const dto = { name: 'example.com' }; + const normalized = 'example.com'; + const created = { id: 1, name: normalized }; + + mockRepo.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }); + mockRepo.create.mockReturnValue(created); + mockRepo.save.mockResolvedValue(created); + + const result = await service.create(dto); + + expect(result).toEqual(created); + expect(repo.create).toHaveBeenCalledWith({ name: normalized }); + }); + + it('должен вернуть существующий домен, если он уже есть', async () => { + const dto = { name: 'example.com' }; + const existing = { id: 1, name: 'example.com' }; + + mockRepo.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(existing), + }); + + const result = await service.create(dto); + + expect(result).toEqual(existing); + expect(repo.create).not.toHaveBeenCalled(); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('должен бросить BadRequestException для некорректного домена', async () => { + const dto = { name: 'not-a-domain' }; + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow( + 'Некорректное доменное имя', + ); + }); + + it('должен нормализовать домен с протоколом и путём', async () => { + const dto = { name: 'https://example.com/path/to/page' }; + const normalized = 'example.com'; + + mockRepo.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }); + mockRepo.create.mockReturnValue({ name: normalized }); + mockRepo.save.mockResolvedValue({ name: normalized }); + + await service.create(dto); + + expect(repo.create).toHaveBeenCalledWith({ name: normalized }); + }); + + it('должен нормализовать wildcard домен *.example.com → example.com', async () => { + const dto = { name: '*.example.com' }; + const normalized = 'example.com'; + + mockRepo.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }); + mockRepo.create.mockReturnValue({ name: normalized }); + mockRepo.save.mockResolvedValue({ name: normalized }); + + await service.create(dto); + + expect(repo.create).toHaveBeenCalledWith({ name: normalized }); + }); + + it('должен игнорировать комментарии в домене', async () => { + const dto = { name: 'example.com # это комментарий' }; + const normalized = 'example.com'; + + mockRepo.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }); + mockRepo.create.mockReturnValue({ name: normalized }); + mockRepo.save.mockResolvedValue({ name: normalized }); + + await service.create(dto); + + expect(repo.create).toHaveBeenCalledWith({ name: normalized }); + }); + }); + + describe('findAll', () => { + it('должен вернуть домены с пагинацией', async () => { + const mockDomains = [ + { id: 1, name: 'ya.ru' }, + { id: 2, name: 'vk.com' }, + ]; + + mockRepo.findAndCount.mockResolvedValue([mockDomains, 100]); + + const result = await service.findAll(1, 10); + + expect(result).toEqual({ + data: mockDomains, + total: 100, + }); + expect(repo.findAndCount).toHaveBeenCalledWith({ + take: 10, + skip: 0, + order: { id: 'DESC' }, + }); + }); + + it('должен использовать значения по умолчанию для пагинации', async () => { + mockRepo.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll(); + + expect(repo.findAndCount).toHaveBeenCalledWith({ + take: 10, + skip: 0, + order: { id: 'DESC' }, + }); + }); + }); + + describe('findAllUnpaginated', () => { + it('должен вернуть все домены без пагинации', async () => { + const mockDomains = [ + { id: 1, name: 'ya.ru' }, + { id: 2, name: 'vk.com' }, + ]; + + mockRepo.find.mockResolvedValue(mockDomains); + + const result = await service.findAllUnpaginated(); + + expect(result).toEqual(mockDomains); + expect(repo.find).toHaveBeenCalledWith({ order: { name: 'ASC' } }); + }); + }); + + describe('findOne', () => { + it('должен вернуть домен по ID', async () => { + const mockDomain = { id: 1, name: 'ya.ru' }; + + mockRepo.findOneBy.mockResolvedValue(mockDomain); + + const result = await service.findOne(1); + + expect(result).toEqual(mockDomain); + expect(repo.findOneBy).toHaveBeenCalledWith({ id: 1 }); + }); + }); + + describe('remove', () => { + it('должен удалить домен по ID', async () => { + mockRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.remove(1); + + expect(repo.delete).toHaveBeenCalledWith(1); + }); + }); + + describe('removeAll', () => { + it('должен удалить все домены', async () => { + mockRepo.clear.mockResolvedValue(undefined); + + const result = await service.removeAll(); + + expect(result).toEqual({ success: true }); + expect(repo.clear).toHaveBeenCalledTimes(1); + }); + }); + + describe('createMany', () => { + it('должен создать несколько доменов', async () => { + const domains = ['ya.ru', 'vk.com', 'ok.ru']; + + mockRepo.find.mockResolvedValue([]); + mockRepo.create.mockReturnValue({ name: 'ya.ru' }); + mockRepo.save.mockResolvedValue({}); + + const result = await service.createMany(domains); + + expect(result.count).toBeGreaterThan(0); + expect(repo.save).toHaveBeenCalled(); + }); + + it('должен пропустить существующие домены', async () => { + const domains = ['ya.ru', 'vk.com']; + const existing = [{ id: 1, name: 'ya.ru' }]; + + mockRepo.find.mockResolvedValue(existing); + + const result = await service.createMany(domains); + + expect(result.count).toBe(1); // Только vk.com будет создан + }); + + it('должен вернуть 0, если все домены уже существуют', async () => { + const domains = ['ya.ru', 'vk.com']; + const existing = [ + { id: 1, name: 'ya.ru' }, + { id: 2, name: 'vk.com' }, + ]; + + mockRepo.find.mockResolvedValue(existing); + + const result = await service.createMany(domains); + + expect(result.count).toBe(0); + }); + + it('должен вернуть 0 для пустого массива', async () => { + const result = await service.createMany([]); + + expect(result.count).toBe(0); + expect(repo.find).not.toHaveBeenCalled(); + }); + + it('должен нормализовать домены при массовом создании', async () => { + const domains = ['*.EXAMPLE.com', 'https://vk.com/path', ' ok.ru ']; + + mockRepo.find.mockResolvedValue([]); + mockRepo.create.mockReturnValue({ name: 'example.com' }); + mockRepo.save.mockResolvedValue({}); + + await service.createMany(domains); + + expect(repo.create).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/inbounds/inbound-builder.service.spec.ts b/server/test/inbounds/inbound-builder.service.spec.ts new file mode 100644 index 0000000..6036ed9 --- /dev/null +++ b/server/test/inbounds/inbound-builder.service.spec.ts @@ -0,0 +1,537 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { InboundBuilderService } from 'src/inbounds/inbound-builder.service'; + +// Mock crypto.randomBytes для детерминированных тестов +jest.mock('crypto', () => ({ + randomBytes: jest.fn().mockReturnValue(Buffer.from('abcd1234', 'hex')), + randomFillSync: jest.fn((buffer: Buffer) => { + for (let i = 0; i < buffer.length; i++) { + buffer[i] = i; + } + return buffer; + }), +})); + +describe('InboundBuilderService', () => { + let service: InboundBuilderService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [InboundBuilderService], + }).compile(); + + service = module.get(InboundBuilderService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('buildVlessRealityTcp', () => { + const params = { + port: 443, + uuid: 'test-uuid-123', + sni: 'ya.ru', + privateKey: 'private-key', + publicKey: 'public-key', + }; + + it('должен создать конфиг vless-tcp-reality', () => { + const result = service.buildVlessRealityTcp(params); + + expect(result).toMatchObject({ + enable: true, + port: 443, + protocol: 'vless', + remark: 'vless-tcp-reality', + }); + + const settings = JSON.parse(result.settings); + expect(settings.clients[0].id).toBe('test-uuid-123'); + expect(settings.clients[0].flow).toBe('xtls-rprx-vision'); + }); + + it('должен установить Reality настройки', () => { + const result = service.buildVlessRealityTcp(params); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.security).toBe('reality'); + expect(streamSettings.realitySettings.target).toBe('ya.ru:443'); + expect(streamSettings.realitySettings.serverNames).toContain('ya.ru'); + expect(streamSettings.realitySettings.privateKey).toBe('private-key'); + }); + + it('должен сгенерировать shortIds', () => { + const result = service.buildVlessRealityTcp(params); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.realitySettings.shortIds).toHaveLength(2); + }); + }); + + describe('buildVlessRealityXhttp', () => { + const params = { + port: 8443, + uuid: 'test-uuid-456', + sni: 'vk.com', + privateKey: 'private-key', + publicKey: 'public-key', + }; + + it('должен создать конфиг vless-xhttp-reality', () => { + const result = service.buildVlessRealityXhttp(params); + + expect(result).toMatchObject({ + enable: true, + port: 8443, + protocol: 'vless', + remark: 'vless-xhttp-reality', + }); + + const settings = JSON.parse(result.settings); + expect(settings.clients[0].id).toBe('test-uuid-456'); + expect(settings.clients[0].flow).toBe(''); + }); + + it('должен установить xhttp настройки', () => { + const result = service.buildVlessRealityXhttp(params); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.security).toBe('reality'); + expect(streamSettings.network).toBe('xhttp'); + }); + }); + + describe('buildVlessRealityGrpc', () => { + const params = { + port: 2053, + uuid: 'test-uuid-789', + sni: 'ok.ru', + privateKey: 'private-key', + publicKey: 'public-key', + }; + + it('должен создать конфиг vless-grpc-reality', () => { + const result = service.buildVlessRealityGrpc(params); + + expect(result).toMatchObject({ + enable: true, + port: 2053, + protocol: 'vless', + remark: 'vless-grpc-reality', + }); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.network).toBe('grpc'); + expect(streamSettings.grpcSettings?.serviceName).toBeTruthy(); + }); + }); + + describe('buildVlessWs', () => { + const params = { + port: 10000, + uuid: 'test-uuid-ws', + sni: 'ozon.ru', + }; + + it('должен создать конфиг vless-ws', () => { + const result = service.buildVlessWs(params); + + expect(result).toMatchObject({ + enable: true, + port: 10000, + protocol: 'vless', + remark: 'vless-ws', + }); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.network).toBe('ws'); + }); + + it('должен установить ws настройки', () => { + const result = service.buildVlessWs(params); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.wsSettings?.path).toBe('/'); + }); + }); + + describe('buildVmessTcp', () => { + const params = { + port: 20000, + uuid: 'test-uuid-vmess', + }; + + it('должен создать конфиг vmess-tcp', () => { + const result = service.buildVmessTcp(params); + + expect(result).toMatchObject({ + enable: true, + port: 20000, + protocol: 'vmess', + remark: 'vmess-tcp', + }); + + const settings = JSON.parse(result.settings); + expect(settings.clients[0].id).toBe('test-uuid-vmess'); + }); + }); + + describe('buildShadowsocksTcp', () => { + const params = { + port: 30000, + uuid: 'test-uuid-ss', + }; + + it('должен создать конфиг shadowsocks-tcp', () => { + const result = service.buildShadowsocksTcp(params); + + expect(result).toMatchObject({ + enable: true, + port: 30000, + protocol: 'shadowsocks', + remark: 'shadowsocks-tcp', + }); + + const settings = JSON.parse(result.settings); + expect(settings.method).toBe('2022-blake3-aes-256-gcm'); + expect(settings.password).toBeTruthy(); // Генерируется из uuid + }); + }); + + describe('buildTrojanRealityTcp', () => { + const params = { + port: 443, + uuid: 'test-uuid-trojan', + sni: 'ya.ru', + privateKey: 'private-key', + publicKey: 'public-key', + }; + + it('должен создать конфиг trojan-tcp-reality', () => { + const result = service.buildTrojanRealityTcp(params); + + expect(result).toMatchObject({ + enable: true, + port: 443, + protocol: 'trojan', + remark: 'trojan-tcp-reality', + }); + + const settings = JSON.parse(result.settings); + expect(settings.clients[0].password).toBeTruthy(); // Генерируется из uuid + }); + + it('должен установить Reality настройки для trojan', () => { + const result = service.buildTrojanRealityTcp(params); + + const streamSettings = JSON.parse(result.streamSettings); + expect(streamSettings.security).toBe('reality'); + expect(streamSettings.realitySettings.target).toBe('ya.ru:443'); + }); + }); + + describe('buildHysteria2Link', () => { + it('должен создать ссылку hysteria2', () => { + const result = service.buildHysteria2Link( + '192.168.1.1', + 'ya.ru', + '%F0%9F%92%AF%20hysteria2', + ); + + expect(result).toContain('hy2://'); + expect(result).toContain('192.168.1.1'); + // SNI может быть IP, если конфиг не найден + }); + + it('должен создать ссылку hysteria2 с портом', () => { + const result = service.buildHysteria2Link( + '192.168.1.1', + 'ya.ru', + '%F0%9F%92%AF%20hysteria2', + 443, + ); + + expect(result).toContain(':443'); + }); + }); + + describe('buildInboundLink', () => { + const baseInbound = { + protocol: 'vless', + port: 443, + remark: 'vless-tcp-reality', + settings: JSON.stringify({ + clients: [{ id: 'test-uuid', flow: 'xtls-rprx-vision' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + security: 'reality', + realitySettings: { + serverNames: ['ya.ru'], + publicKey: 'public-key', + }, + }), + }; + + it('должен создать ссылку vless reality', () => { + const result = service.buildInboundLink( + baseInbound as any, + '192.168.1.1', + 'test-uuid', + '%F0%9F%92%AF', + ); + + expect(result).toContain('vless://'); + expect(result).toContain('192.168.1.1'); + expect(result).toContain('443'); + }); + + it('должен создать ссылку vless reality с xhttp', () => { + const inbound = { + protocol: 'vless', + port: 8443, + remark: 'vless-xhttp-reality', + settings: JSON.stringify({ + clients: [{ id: 'test-uuid', flow: '' }], + }), + streamSettings: JSON.stringify({ + network: 'xhttp', + security: 'reality', + realitySettings: { + serverNames: ['ya.ru'], + publicKey: 'public-key', + settings: { publicKey: 'pk', fingerprint: 'random' }, + shortIds: ['abc123'], + }, + xhttpSettings: { + path: '/path', + host: 'ya.ru', + mode: 'auto', + }, + }), + }; + + const result = service.buildInboundLink( + inbound as any, + '192.168.1.1', + 'test-uuid', + '%F0%9F%92%AF', + ); + + expect(result).toContain('vless://'); + expect(result).toContain('type=xhttp'); + expect(result).toContain('path=%2Fpath'); + }); + + it('должен создать ссылку vless reality с grpc', () => { + const inbound = { + protocol: 'vless', + port: 8443, + remark: 'vless-grpc-reality', + settings: JSON.stringify({ + clients: [{ id: 'test-uuid', flow: '' }], + }), + streamSettings: JSON.stringify({ + network: 'grpc', + security: 'reality', + realitySettings: { + serverNames: ['ya.ru'], + publicKey: 'public-key', + settings: { publicKey: 'pk', fingerprint: 'random' }, + shortIds: ['abc123'], + }, + grpcSettings: { + serviceName: 'grpc-service', + authority: 'ya.ru', + }, + }), + }; + + const result = service.buildInboundLink( + inbound as any, + '192.168.1.1', + 'test-uuid', + '%F0%9F%92%AF', + ); + + expect(result).toContain('vless://'); + expect(result).toContain('type=grpc'); + expect(result).toContain('serviceName=grpc-service'); + }); + + it('должен создать ссылку vless с ws', () => { + const inbound = { + protocol: 'vless', + port: 443, + remark: 'vless-ws', + settings: JSON.stringify({ + clients: [{ id: 'test-uuid' }], + }), + streamSettings: JSON.stringify({ + network: 'ws', + security: 'tls', + wsSettings: { + path: '/ws', + headers: { Host: 'example.com' }, + }, + }), + }; + + const result = service.buildInboundLink( + inbound as any, + '192.168.1.1', + 'test-uuid', + '%F0%9F%92%AF', + ); + + expect(result).toContain('vless://'); + expect(result).toContain('type=ws'); + expect(result).toContain('path=%2Fws'); + }); + + it('должен создать ссылку vmess', () => { + const vmessInbound = { + protocol: 'vmess', + port: 20000, + remark: 'vmess-tcp', + settings: JSON.stringify({ + clients: [{ id: 'test-uuid' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + }), + }; + + const result = service.buildInboundLink( + vmessInbound as any, + '192.168.1.1', + 'test-uuid', + '%F0%9F%92%AF', + ); + + expect(result).toContain('vmess://'); + }); + + it('должен создать ссылку shadowsocks', () => { + const ssInbound = { + protocol: 'shadowsocks', + port: 30000, + remark: 'shadowsocks-tcp', + settings: JSON.stringify({ + method: '2022-blake3-aes-256-gcm', + password: 'test-password', + clients: [{ password: 'client-pass' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + }), + }; + + const result = service.buildInboundLink( + ssInbound as any, + '192.168.1.1', + '', + '%F0%9F%92%AF', + ); + + expect(result).toContain('ss://'); + }); + + it('должен создать ссылку trojan', () => { + const trojanInbound = { + protocol: 'trojan', + port: 443, + remark: 'trojan-reality', + settings: JSON.stringify({ + clients: [{ password: 'trojan-pass' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + security: 'reality', + realitySettings: { + serverNames: ['ya.ru'], + publicKey: 'public-key', + settings: { publicKey: 'pk', fingerprint: 'random' }, + shortIds: ['abc123'], + }, + }), + }; + + const result = service.buildInboundLink( + trojanInbound as any, + '192.168.1.1', + 'trojan-pass', + '%F0%9F%92%AF', + ); + + expect(result).toContain('trojan://'); + expect(result).toContain('security=reality'); + }); + + it('должен вернуть пустую строку для trojan без reality', () => { + const trojanInbound = { + protocol: 'trojan', + port: 443, + remark: 'trojan', + settings: JSON.stringify({ + clients: [{ password: 'pass' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + security: 'none', + }), + }; + + const result = service.buildInboundLink( + trojanInbound as any, + '192.168.1.1', + 'pass', + '%F0%9F%92%AF', + ); + + expect(result).toBe(''); + }); + + it('должен вернуть пустую строку для vless без reality settings', () => { + const vlessInbound = { + protocol: 'vless', + port: 443, + remark: 'vless', + settings: JSON.stringify({ + clients: [{ id: 'uuid' }], + }), + streamSettings: JSON.stringify({ + network: 'tcp', + security: 'reality', + realitySettings: null, + }), + }; + + const result = service.buildInboundLink( + vlessInbound as any, + '192.168.1.1', + 'uuid', + '%F0%9F%92%AF', + ); + + expect(result).toBe(''); + }); + }); + + describe('generateUuid', () => { + it('должен сгенерировать UUID', () => { + const uuid = service.generateUuid(); + + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + }); +}); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f..9941182 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -5,5 +5,12 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "transformIgnorePatterns": [ + "node_modules/(?!(uuid)/)" + ], + "moduleNameMapper": { + "^src/(.*)$": "/../src/$1" + }, + "setupFilesAfterEnv": ["/setup.ts"] } diff --git a/server/test/jest-global-setup.ts b/server/test/jest-global-setup.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/test/rotation/rotation.controller.spec.ts b/server/test/rotation/rotation.controller.spec.ts new file mode 100644 index 0000000..0378f8d --- /dev/null +++ b/server/test/rotation/rotation.controller.spec.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { RotationController } from 'src/rotation/rotation.controller'; +import { RotationService } from 'src/rotation/rotation.service'; + +describe('RotationController', () => { + let controller: RotationController; + let rotationService: RotationService; + + const mockRotationService = { + performRotation: jest.fn(), + rotateSingleSubscription: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RotationController], + providers: [ + { + provide: RotationService, + useValue: mockRotationService, + }, + ], + }).compile(); + + controller = module.get(RotationController); + rotationService = module.get(RotationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rotateAll', () => { + it('должен запустить плановую ротацию', async () => { + const mockResult = { success: true, message: 'Ротация выполнена' }; + + mockRotationService.performRotation.mockResolvedValue(mockResult); + + const result = await controller.rotateAll(); + + expect(result).toEqual(mockResult); + expect(rotationService.performRotation).toHaveBeenCalledTimes(1); + }); + + it('должен вернуть ошибку ротации', async () => { + const mockError = { success: false, message: 'Нет подписок' }; + + mockRotationService.performRotation.mockResolvedValue(mockError); + + const result = await controller.rotateAll(); + + expect(result).toEqual(mockError); + }); + }); + + describe('rotateSingle', () => { + it('должен запустить ротацию одной подписки', async () => { + const mockResult = { + success: true, + message: 'Ротация подписки выполнена', + }; + + mockRotationService.rotateSingleSubscription.mockResolvedValue( + mockResult, + ); + + const result = await controller.rotateSingle('sub-123'); + + expect(result).toEqual(mockResult); + expect(rotationService.rotateSingleSubscription).toHaveBeenCalledWith( + 'sub-123', + ); + }); + + it('должен вернуть ошибку ротации одной подписки', async () => { + const mockError = { success: false, message: 'Подписка не найдена' }; + + mockRotationService.rotateSingleSubscription.mockResolvedValue(mockError); + + const result = await controller.rotateSingle('non-existent'); + + expect(result).toEqual(mockError); + }); + }); +}); diff --git a/server/test/rotation/rotation.service.spec.ts b/server/test/rotation/rotation.service.spec.ts new file mode 100644 index 0000000..99884ad --- /dev/null +++ b/server/test/rotation/rotation.service.spec.ts @@ -0,0 +1,550 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RotationService } from 'src/rotation/rotation.service'; +import { Subscription } from 'src/subscriptions/entities/subscription.entity'; +import { Inbound } from 'src/inbounds/entities/inbound.entity'; +import { Domain } from 'src/domains/entities/domain.entity'; +import { Setting } from 'src/settings/entities/setting.entity'; +import { XuiService } from 'src/xui/xui.service'; +import { InboundBuilderService } from 'src/inbounds/inbound-builder.service'; + +// Mock @nestjs/schedule для тестирования Cron +jest.mock('@nestjs/schedule', () => ({ + Cron: () => () => {}, + CronExpression: { EVERY_MINUTE: '* * * * *' }, +})); + +describe('RotationService', () => { + let service: RotationService; + let _subRepo: Repository; + let inboundRepo: Repository; + let _domainRepo: Repository; + let settingRepo: Repository; + let xuiService: XuiService; + let inboundBuilder: InboundBuilderService; + + const mockSubRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + getOne: jest.fn(), + })), + }; + + const mockInboundRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const mockDomainRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockSettingRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockXuiService = { + login: jest.fn(), + deleteInbound: jest.fn(), + addInbound: jest.fn(), + getNewX25519Cert: jest.fn(), + }; + + const mockInboundBuilder = { + buildVlessRealityTcp: jest.fn(), + buildVlessRealityXhttp: jest.fn(), + buildVlessRealityGrpc: jest.fn(), + buildVlessWs: jest.fn(), + buildVmessTcp: jest.fn(), + buildShadowsocksTcp: jest.fn(), + buildTrojanRealityTcp: jest.fn(), + buildHysteria2Link: jest.fn(), + buildInboundLink: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RotationService, + { + provide: getRepositoryToken(Subscription), + useValue: mockSubRepo, + }, + { + provide: getRepositoryToken(Inbound), + useValue: mockInboundRepo, + }, + { + provide: getRepositoryToken(Domain), + useValue: mockDomainRepo, + }, + { + provide: getRepositoryToken(Setting), + useValue: mockSettingRepo, + }, + { + provide: XuiService, + useValue: mockXuiService, + }, + { + provide: InboundBuilderService, + useValue: mockInboundBuilder, + }, + ], + }).compile(); + + service = module.get(RotationService); + _subRepo = module.get>( + getRepositoryToken(Subscription), + ); + inboundRepo = module.get>(getRepositoryToken(Inbound)); + _domainRepo = module.get>(getRepositoryToken(Domain)); + settingRepo = module.get>(getRepositoryToken(Setting)); + xuiService = module.get(XuiService); + inboundBuilder = module.get(InboundBuilderService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('onModuleInit', () => { + it('должен инициализировать настройки по умолчанию', async () => { + mockSettingRepo.findOne.mockResolvedValue(null); + mockSettingRepo.create.mockReturnValue({ key: 'test', value: 'test' }); + mockSettingRepo.save.mockResolvedValue({}); + + await service.onModuleInit(); + + expect(settingRepo.findOne).toHaveBeenCalled(); + expect(settingRepo.save).toHaveBeenCalled(); + }); + + it('НЕ должен создавать настройки, если они уже есть', async () => { + mockSettingRepo.findOne.mockResolvedValue({ + key: 'rotation_status', + value: 'active', + }); + + await service.onModuleInit(); + + expect(settingRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe('handleTicker', () => { + it('НЕ должен запускать ротацию, если прошло мало времени', async () => { + mockSettingRepo.findOne + .mockResolvedValueOnce({ key: 'rotation_interval', value: '30' }) + .mockResolvedValueOnce({ + key: 'last_rotation_timestamp', + value: Date.now().toString(), + }) + .mockResolvedValueOnce({ key: 'rotation_status', value: 'active' }); + + await (service as any).handleTicker(); + + expect(xuiService.login).not.toHaveBeenCalled(); + }); + + it('НЕ должен запускать ротацию, если статус stopped', async () => { + mockSettingRepo.findOne + .mockResolvedValueOnce({ key: 'rotation_interval', value: '30' }) + .mockResolvedValueOnce({ key: 'last_rotation_timestamp', value: '0' }) + .mockResolvedValueOnce({ key: 'rotation_status', value: 'stopped' }); + + await (service as any).handleTicker(); + + expect(xuiService.login).not.toHaveBeenCalled(); + }); + + it('должен запустить ротацию, если пришло время', async () => { + mockSettingRepo.findOne + .mockResolvedValueOnce({ key: 'rotation_interval', value: '1' }) + .mockResolvedValueOnce({ key: 'last_rotation_timestamp', value: '0' }) + .mockResolvedValueOnce({ key: 'rotation_status', value: 'active' }); + + mockSubRepo.find.mockResolvedValue([ + { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + isAutoRotationEnabled: true, + inbounds: [], + inboundsConfig: [], + }, + ]); + mockDomainRepo.find.mockResolvedValue([]); // Пустой список доменов + + await (service as any).handleTicker(); + + // Ротация запустится, но завершится с ошибкой (пустые домены) + expect(xuiService.login).toHaveBeenCalled(); + }); + }); + + describe('performRotation', () => { + it('должен вернуть ошибку, если не удалось войти в 3x-ui', async () => { + mockXuiService.login.mockResolvedValue(false); + + const result = await service.performRotation(); + + expect(result).toEqual({ + success: false, + message: 'Не удалось войти в панель 3x-ui', + }); + }); + + it('должен вернуть ошибку, если нет активных подписок', async () => { + mockXuiService.login.mockResolvedValue(true); + mockSubRepo.find.mockResolvedValue([]); + + const result = await service.performRotation(); + + expect(result).toEqual({ + success: false, + message: 'Нет активных подписок для ротации', + }); + }); + + it('должен вернуть ошибку, если список доменов пуст', async () => { + mockXuiService.login.mockResolvedValue(true); + mockSubRepo.find.mockResolvedValue([ + { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + isAutoRotationEnabled: true, + inbounds: [], + }, + ]); + mockDomainRepo.find.mockResolvedValue([]); + + const result = await service.performRotation(); + + expect(result).toEqual({ + success: false, + message: 'Список доменов пуст!', + }); + }); + + it('должен выполнить ротацию подписок', async () => { + mockXuiService.login.mockResolvedValue(true); + mockSubRepo.find.mockResolvedValue([ + { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + isAutoRotationEnabled: true, + inbounds: [], + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' }, + ], + }, + ]); + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockXuiService.getNewX25519Cert.mockResolvedValue({ + privateKey: 'key', + publicKey: 'pub', + }); + mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({ + protocol: 'vless', + remark: 'test', + settings: '{}', + streamSettings: '{}', + sniffing: '{}', + }); + mockXuiService.addInbound.mockResolvedValue(101); + mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link'); + + const result = await service.performRotation(); + + expect(result).toEqual({ + success: true, + message: 'Ротация успешно выполнена', + }); + }); + }); + + describe('rotateSingleSubscription', () => { + it('должен вернуть ошибку, если подписка не найдена', async () => { + mockSubRepo.findOne.mockResolvedValue(null); + + const result = await service.rotateSingleSubscription('non-existent'); + + expect(result).toEqual({ + success: false, + message: 'Подписка не найдена', + }); + }); + + it('должен вернуть ошибку, если не удалось войти в 3x-ui', async () => { + mockSubRepo.findOne.mockResolvedValue({ + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [], + }); + mockXuiService.login.mockResolvedValue(false); + + const result = await service.rotateSingleSubscription('1'); + + expect(result).toEqual({ + success: false, + message: 'Не удалось войти в панель 3x-ui', + }); + }); + + it('должен вернуть ошибку, если список доменов пуст', async () => { + mockSubRepo.findOne.mockResolvedValue({ + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [], + }); + mockXuiService.login.mockResolvedValue(true); + mockDomainRepo.find.mockResolvedValue([]); + + const result = await service.rotateSingleSubscription('1'); + + expect(result).toEqual({ + success: false, + message: 'Список доменов пуст!', + }); + }); + + it('должен выполнить ротацию одной подписки', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' }, + ], + }; + + mockSubRepo.findOne.mockResolvedValue(mockSub); + mockXuiService.login.mockResolvedValue(true); + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockXuiService.getNewX25519Cert.mockResolvedValue({ + privateKey: 'key', + publicKey: 'pub', + }); + mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({ + protocol: 'vless', + remark: 'test', + settings: '{}', + streamSettings: '{}', + sniffing: '{}', + }); + mockXuiService.addInbound.mockResolvedValue(101); + mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link'); + + const result = await service.rotateSingleSubscription('1'); + + expect(result).toEqual({ + success: true, + message: 'Ротация успешно выполнена', + }); + }); + + it('должен пропустить подписку, если не удалось получить Reality ключи', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' }, + ], + }; + + mockSubRepo.findOne.mockResolvedValue(mockSub); + mockXuiService.login.mockResolvedValue(true); + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockXuiService.getNewX25519Cert.mockResolvedValue(null); + + // Метод не возвращает результат явно, просто логирует ошибку + await service.rotateSingleSubscription('1'); + + expect(xuiService.getNewX25519Cert).toHaveBeenCalled(); + }); + }); + + describe('rotateSubscription (private)', () => { + it('должен удалить старые инбаунды перед ротацией', async () => { + const mockInbound = { + id: 'inb-1', + xuiId: 101, + port: 443, + protocol: 'vless', + remark: 'test', + link: 'link', + subscription: null as any, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [mockInbound], + inboundsConfig: [], + }; + + mockInbound.subscription = mockSub; + + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockXuiService.getNewX25519Cert.mockResolvedValue({ + privateKey: 'key', + publicKey: 'pub', + }); + + await (service as any).rotateSubscription(mockSub, [ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + + expect(xuiService.deleteInbound).toHaveBeenCalledWith(101); + expect(inboundRepo.delete).toHaveBeenCalled(); + }); + + it('должен обработать custom инбаунд без 3x-ui', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [{ type: 'custom', link: 'custom://link' }], + }; + + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockInboundRepo.save.mockResolvedValue({}); + + await (service as any).rotateSubscription(mockSub, [ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + + expect(xuiService.addInbound).not.toHaveBeenCalled(); + expect(inboundRepo.save).toHaveBeenCalled(); + }); + + it('должен обработать hysteria2-udp инбаунд', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }], + }; + + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockInboundBuilder.buildHysteria2Link.mockReturnValue('hy2://link'); + mockInboundRepo.save.mockResolvedValue({}); + + await (service as any).rotateSubscription(mockSub, [ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + + expect(xuiService.addInbound).not.toHaveBeenCalled(); + expect(inboundBuilder.buildHysteria2Link).toHaveBeenCalled(); + }); + + it('должен использовать случайный порт, если указано random', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 'random', sni: 'ya.ru' }, + ], + }; + + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockInboundRepo.findOne.mockResolvedValue(null); // Порт свободен + mockXuiService.getNewX25519Cert.mockResolvedValue({ + privateKey: 'key', + publicKey: 'pub', + }); + mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({ + protocol: 'vless', + remark: 'test', + settings: '{"clients":[{"id":"uuid"}]}', + streamSettings: '{}', + sniffing: '{}', + }); + mockXuiService.addInbound.mockResolvedValue(101); + mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link'); + mockInboundRepo.save.mockResolvedValue({}); + + await (service as any).rotateSubscription(mockSub, [ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + + expect(inboundRepo.findOne).toHaveBeenCalled(); + }); + + it('должен обработать неизвестный тип инбаунда', async () => { + const mockSub = { + id: '1', + uuid: 'uuid-1', + isEnabled: true, + inbounds: [], + inboundsConfig: [{ type: 'unknown-protocol', port: 443, sni: 'ya.ru' }], + }; + + mockDomainRepo.find.mockResolvedValue([ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + mockXuiService.getNewX25519Cert.mockResolvedValue({ + privateKey: 'key', + publicKey: 'pub', + }); + + await (service as any).rotateSubscription(mockSub, [ + { id: 1, name: 'ya.ru', isEnabled: true }, + ]); + + expect(xuiService.addInbound).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/session/session.service.spec.ts b/server/test/session/session.service.spec.ts new file mode 100644 index 0000000..76866df --- /dev/null +++ b/server/test/session/session.service.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SessionService } from 'src/session/session.service'; + +describe('SessionService', () => { + let service: SessionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SessionService], + }).compile(); + + service = module.get(SessionService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('setFromHeaders', () => { + it('должен установить cookie из заголовка (массив)', () => { + const cookieHeader = ['session=abc123; Path=/; HttpOnly']; + + service.setFromHeaders(cookieHeader); + + const cookie = service.getCookie(); + expect(cookie).toBe('session=abc123'); + }); + + it('должен установить cookie без атрибутов', () => { + const cookieHeader = ['session=xyz789']; + + service.setFromHeaders(cookieHeader); + + const cookie = service.getCookie(); + expect(cookie).toBe('session=xyz789'); + }); + + it('должен обработать несколько cookie', () => { + const cookieHeader = ['session=abc; Path=/', 'other=xyz']; + + service.setFromHeaders(cookieHeader); + + const cookie = service.getCookie(); + expect(cookie).toContain('session=abc'); + }); + }); + + describe('getCookie', () => { + it('должен вернуть null, если cookie не установлен', () => { + const cookie = service.getCookie(); + + expect(cookie).toBeNull(); + }); + + it('должен вернуть установленный cookie', () => { + service.setFromHeaders(['session=test123']); + + const cookie = service.getCookie(); + expect(cookie).toBe('session=test123'); + }); + }); + + describe('clear', () => { + it('должен очистить cookie', () => { + service.setFromHeaders(['session=test123']); + service.clear(); + + const cookie = service.getCookie(); + expect(cookie).toBeNull(); + }); + }); +}); diff --git a/server/test/settings/settings.controller.spec.ts b/server/test/settings/settings.controller.spec.ts new file mode 100644 index 0000000..03bd350 --- /dev/null +++ b/server/test/settings/settings.controller.spec.ts @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SettingsController } from 'src/settings/settings.controller'; +import { Setting } from 'src/settings/entities/setting.entity'; +import { XuiService } from 'src/xui/xui.service'; + +describe('SettingsController', () => { + let controller: SettingsController; + let settingsRepo: Repository; + let xuiService: XuiService; + + const mockSettingsRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockXuiService = { + checkConnection: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SettingsController], + providers: [ + { + provide: getRepositoryToken(Setting), + useValue: mockSettingsRepo, + }, + { + provide: XuiService, + useValue: mockXuiService, + }, + ], + }).compile(); + + controller = module.get(SettingsController); + settingsRepo = module.get>(getRepositoryToken(Setting)); + xuiService = module.get(XuiService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('должен вернуть все настройки как объект', async () => { + const mockSettings = [ + { key: 'xui_url', value: 'http://localhost:3000' }, + { key: 'xui_login', value: 'admin' }, + { key: 'xui_password', value: 'password' }, + ]; + + mockSettingsRepo.find.mockResolvedValue(mockSettings); + + const result = await controller.findAll(); + + expect(result).toEqual({ + xui_url: 'http://localhost:3000', + xui_login: 'admin', + xui_password: 'password', + }); + expect(settingsRepo.find).toHaveBeenCalledTimes(1); + }); + + it('должен вернуть пустой объект, если настроек нет', async () => { + mockSettingsRepo.find.mockResolvedValue([]); + + const result = await controller.findAll(); + + expect(result).toEqual({}); + }); + }); + + describe('checkConnection', () => { + it('должен проверить подключение к 3x-ui', async () => { + const body = { + xui_url: 'http://localhost:3000', + xui_login: 'admin', + xui_password: 'password', + }; + + mockXuiService.checkConnection.mockResolvedValue(true); + + const result = await controller.checkConnection(body); + + expect(result).toEqual({ success: true }); + expect(xuiService.checkConnection).toHaveBeenCalledWith( + body.xui_url, + body.xui_login, + body.xui_password, + ); + }); + + it('должен вернуть false при неудачном подключении', async () => { + const body = { + xui_url: 'http://localhost:3000', + xui_login: 'admin', + xui_password: 'wrong', + }; + + mockXuiService.checkConnection.mockResolvedValue(false); + + const result = await controller.checkConnection(body); + + expect(result).toEqual({ success: false }); + }); + }); + + describe('update', () => { + it('должен сохранить настройки без xui_url', async () => { + const settings = { + xui_login: 'newAdmin', + xui_password: 'newPassword', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalledTimes(2); + }); + + it('должен извлечь host из xui_url и определить IP', async () => { + const settings = { + xui_url: 'http://example.com:8080', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + + it('должен определить страну по IP через GeoIP', async () => { + const settings = { + xui_url: 'http://8.8.8.8:8080', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + // Mock для fetch (GeoIP API) + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + status: 'success', + countryCode: 'US', + country: 'United States', + }), + }); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + + it('должен обработать ошибку GeoIP', async () => { + const settings = { + xui_url: 'http://8.8.8.8:8080', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + status: 'fail', + message: 'Reserved IP', + }), + }); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + }); + + it('должен обработать ошибку при некорректном URL', async () => { + const settings = { + xui_url: 'not-a-valid-url', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + + it('должен использовать localhost без GeoIP запроса', async () => { + const settings = { + xui_url: 'http://localhost:8080', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + + it('должен использовать 127.0.0.1 без GeoIP запроса', async () => { + const settings = { + xui_url: 'http://127.0.0.1:8080', + }; + + mockSettingsRepo.save.mockResolvedValue({}); + + const result = await controller.update(settings); + + expect(result).toEqual({ success: true }); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/setup.ts b/server/test/setup.ts new file mode 100644 index 0000000..be321cb --- /dev/null +++ b/server/test/setup.ts @@ -0,0 +1,4 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.join(__dirname, '../.env.test') }); diff --git a/server/test/subscriptions/subscriptions.controller.spec.ts b/server/test/subscriptions/subscriptions.controller.spec.ts new file mode 100644 index 0000000..5a2ab9c --- /dev/null +++ b/server/test/subscriptions/subscriptions.controller.spec.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionsController } from 'src/subscriptions/subscriptions.controller'; +import { SubscriptionsService } from 'src/subscriptions/subscriptions.service'; +import { NotFoundException } from '@nestjs/common'; +import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto'; +import { UpdateSubscriptionDto } from 'src/subscriptions/dto/update-subscription.dto'; + +describe('SubscriptionsController', () => { + let controller: SubscriptionsController; + let service: SubscriptionsService; + + const mockSubscriptionsService = { + findAll: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SubscriptionsController], + providers: [ + { + provide: SubscriptionsService, + useValue: mockSubscriptionsService, + }, + ], + }).compile(); + + controller = module.get(SubscriptionsController); + service = module.get(SubscriptionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('должен вернуть все подписки', async () => { + const mockSubs = [ + { id: '1', name: 'Sub 1', uuid: 'uuid-1' }, + { id: '2', name: 'Sub 2', uuid: 'uuid-2' }, + ]; + mockSubscriptionsService.findAll.mockResolvedValue(mockSubs); + + const result = await controller.findAll(); + + expect(result).toEqual(mockSubs); + expect(service.findAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('create', () => { + const createDto: CreateSubscriptionDto = { + name: 'Test Subscription', + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 443, sni: 'example.com' }, + ], + isAutoRotationEnabled: true, + }; + + it('должен создать подписку', async () => { + const mockSubscription = { id: 'new-id', ...createDto }; + mockSubscriptionsService.create.mockResolvedValue(mockSubscription); + + const result = await controller.create(createDto); + + expect(result).toEqual(mockSubscription); + expect(service.create).toHaveBeenCalledWith(createDto); + }); + }); + + describe('bulkUpdateAutoRotation', () => { + it('должен вернуть ошибку, если subscriptionIds не массив', async () => { + const result = await controller.bulkUpdateAutoRotation({ + subscriptionIds: 'not-array' as any, + enabled: true, + }); + + expect(result).toEqual({ + success: false, + message: 'subscriptionIds должен быть массивом', + }); + }); + + it('должен вернуть ошибку, если больше 100 ID', async () => { + const result = await controller.bulkUpdateAutoRotation({ + subscriptionIds: Array(101).fill('id'), + enabled: true, + }); + + expect(result).toEqual({ + success: false, + message: 'Максимум 100 ID за раз', + }); + }); + + it('должен обновить подписки и вернуть отчёт', async () => { + const ids = ['id1', 'id2', 'id3']; + mockSubscriptionsService.update + .mockResolvedValueOnce({ id: 'id1' }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'id3' }); + + const result = await controller.bulkUpdateAutoRotation({ + subscriptionIds: ids, + enabled: true, + }); + + expect(result).toEqual({ + success: true, + message: 'Обновлено 2 подписок', + updatedCount: 2, + notFound: ['id2'], + }); + }); + + it('должен вернуть пустой notFound, если все обновлены успешно', async () => { + const ids = ['id1', 'id2']; + mockSubscriptionsService.update.mockResolvedValue({ id: 'updated' }); + + const result = await controller.bulkUpdateAutoRotation({ + subscriptionIds: ids, + enabled: false, + }); + + expect(result).toEqual({ + success: true, + message: 'Обновлено 2 подписок', + updatedCount: 2, + notFound: undefined, + }); + }); + + it('должен обработать пустой массив', async () => { + const result = await controller.bulkUpdateAutoRotation({ + subscriptionIds: [], + enabled: true, + }); + + expect(result).toEqual({ + success: true, + message: 'Обновлено 0 подписок', + updatedCount: 0, + notFound: undefined, + }); + }); + }); + + describe('update', () => { + const updateDto: UpdateSubscriptionDto = { + name: 'Updated Name', + isAutoRotationEnabled: false, + }; + + it('должен обновить подписку', async () => { + const mockSub = { id: 'id1', ...updateDto }; + mockSubscriptionsService.update.mockResolvedValue(mockSub); + + const result = await controller.update('id1', updateDto); + + expect(result).toEqual(mockSub); + expect(service.update).toHaveBeenCalledWith('id1', updateDto); + }); + + it('должен бросить NotFoundException, если подписка не найдена', async () => { + mockSubscriptionsService.update.mockResolvedValue(null); + + await expect( + controller.update('non-existent', updateDto), + ).rejects.toThrow(NotFoundException); + + await expect( + controller.update('non-existent', updateDto), + ).rejects.toThrow('Подписка non-existent не найдена'); + }); + }); + + describe('remove', () => { + it('должен удалить подписку', async () => { + mockSubscriptionsService.remove.mockResolvedValue(undefined); + + await controller.remove('id1'); + + expect(service.remove).toHaveBeenCalledWith('id1'); + }); + }); +}); diff --git a/server/test/subscriptions/subscriptions.service.spec.ts b/server/test/subscriptions/subscriptions.service.spec.ts new file mode 100644 index 0000000..443c894 --- /dev/null +++ b/server/test/subscriptions/subscriptions.service.spec.ts @@ -0,0 +1,362 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SubscriptionsService } from 'src/subscriptions/subscriptions.service'; +import { Subscription } from 'src/subscriptions/entities/subscription.entity'; +import { XuiService } from 'src/xui/xui.service'; +import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto'; + +describe('SubscriptionsService', () => { + let service: SubscriptionsService; + let subRepo: Repository; + let xuiService: XuiService; + + const mockSubRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), + }; + + const mockXuiService = { + deleteInbound: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubscriptionsService, + { + provide: getRepositoryToken(Subscription), + useValue: mockSubRepo, + }, + { + provide: XuiService, + useValue: mockXuiService, + }, + ], + }).compile(); + + service = module.get(SubscriptionsService); + subRepo = module.get>( + getRepositoryToken(Subscription), + ); + xuiService = module.get(XuiService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('должен вернуть все подписки с relations и сортировкой', async () => { + const mockSubs: Subscription[] = [ + { + id: '1', + name: 'Sub 1', + uuid: 'uuid-1', + isEnabled: true, + isAutoRotationEnabled: true, + inboundsConfig: [], + inbounds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2', + name: 'Sub 2', + uuid: 'uuid-2', + isEnabled: true, + isAutoRotationEnabled: false, + inboundsConfig: [], + inbounds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockSubRepo.find.mockResolvedValue(mockSubs); + + const result = await service.findAll(); + + expect(result).toEqual(mockSubs); + expect(subRepo.find).toHaveBeenCalledWith({ + relations: ['inbounds'], + order: { createdAt: 'DESC' }, + }); + }); + + it('должен вернуть пустой массив, если подписок нет', async () => { + mockSubRepo.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + }); + + describe('create', () => { + const createDto: CreateSubscriptionDto = { + name: 'Test Subscription', + inboundsConfig: [ + { type: 'vless-tcp-reality', port: 443, sni: 'example.com' }, + ], + isAutoRotationEnabled: true, + }; + + it('должен создать подписку с UUID и настройками по умолчанию', async () => { + const mockSubscription = { + ...createDto, + id: 'test-id', + uuid: 'generated-uuid', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockSubRepo.create.mockReturnValue(mockSubscription); + mockSubRepo.save.mockResolvedValue(mockSubscription); + + const result = await service.create(createDto); + + expect(subRepo.create).toHaveBeenCalledWith({ + name: 'Test Subscription', + uuid: expect.any(String), + inboundsConfig: createDto.inboundsConfig, + isAutoRotationEnabled: true, + }); + expect(subRepo.save).toHaveBeenCalledWith(mockSubscription); + expect(result).toEqual(mockSubscription); + }); + + it('должен использовать inboundsConfig по умолчанию [], если не передан', async () => { + const dtoWithoutConfig: CreateSubscriptionDto = { + name: 'Test', + isAutoRotationEnabled: false, + }; + + mockSubRepo.create.mockReturnValue({ ...dtoWithoutConfig, uuid: 'uuid' }); + mockSubRepo.save.mockResolvedValue({ ...dtoWithoutConfig, uuid: 'uuid' }); + + await service.create(dtoWithoutConfig); + + expect(subRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + inboundsConfig: [], + }), + ); + }); + + it('должен использовать isAutoRotationEnabled=true по умолчанию', async () => { + const dtoWithoutRotation: CreateSubscriptionDto = { + name: 'Test', + inboundsConfig: [], + }; + + mockSubRepo.create.mockReturnValue({ + ...dtoWithoutRotation, + uuid: 'uuid', + }); + mockSubRepo.save.mockResolvedValue({ + ...dtoWithoutRotation, + uuid: 'uuid', + }); + + await service.create(dtoWithoutRotation); + + expect(subRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + isAutoRotationEnabled: true, + }), + ); + }); + }); + + describe('update', () => { + const existingSub: Subscription = { + id: 'test-id', + name: 'Old Name', + uuid: 'uuid', + isEnabled: true, + isAutoRotationEnabled: true, + inboundsConfig: [], + inbounds: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('должен обновить имя подписки', async () => { + mockSubRepo.findOne.mockResolvedValue(existingSub); + mockSubRepo.save.mockResolvedValue({ ...existingSub, name: 'New Name' }); + + const result = await service.update('test-id', { name: 'New Name' }); + + expect(subRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'test-id' }, + relations: ['inbounds'], + }); + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ name: 'New Name' }), + ); + expect(result).toEqual({ ...existingSub, name: 'New Name' }); + }); + + it('должен обновить isAutoRotationEnabled', async () => { + mockSubRepo.findOne.mockResolvedValue(existingSub); + mockSubRepo.save.mockResolvedValue({ + ...existingSub, + isAutoRotationEnabled: false, + }); + + const result = await service.update('test-id', { + isAutoRotationEnabled: false, + }); + + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ isAutoRotationEnabled: false }), + ); + expect(result).toEqual({ ...existingSub, isAutoRotationEnabled: false }); + }); + + it('должен обновить inboundsConfig', async () => { + const newConfig = [{ type: 'vmess-tcp', port: 8080 }]; + mockSubRepo.findOne.mockResolvedValue(existingSub); + mockSubRepo.save.mockResolvedValue({ + ...existingSub, + inboundsConfig: newConfig, + }); + + await service.update('test-id', { inboundsConfig: newConfig }); + + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ inboundsConfig: newConfig }), + ); + }); + + it('НЕ должен обновлять имя на пустую строку (защита от очистки)', async () => { + mockSubRepo.findOne.mockResolvedValue(existingSub); + mockSubRepo.save.mockResolvedValue(existingSub); + + await service.update('test-id', { name: '' }); + + expect(subRepo.save).not.toHaveBeenCalledWith( + expect.objectContaining({ name: '' }), + ); + expect(subRepo.save).toHaveBeenCalledWith(existingSub); + }); + + it('НЕ должен обновлять имя с пробелами (защита от очистки)', async () => { + mockSubRepo.findOne.mockResolvedValue(existingSub); + mockSubRepo.save.mockResolvedValue(existingSub); + + await service.update('test-id', { name: ' ' }); + + expect(subRepo.save).toHaveBeenCalledWith(existingSub); + }); + + it('должен вернуть null, если подписка не найдена', async () => { + mockSubRepo.findOne.mockResolvedValue(null); + + const result = await service.update('non-existent-id', { name: 'New' }); + + expect(result).toBeNull(); + expect(subRepo.save).not.toHaveBeenCalled(); + }); + + it('должен обновить только isAutoRotationEnabled, не трогая имя', async () => { + const freshSub: Subscription = { + ...existingSub, + name: 'Old Name', + inboundsConfig: [], + }; + mockSubRepo.findOne.mockResolvedValue(freshSub); + mockSubRepo.save.mockImplementation((sub) => Promise.resolve(sub)); + + await service.update('test-id', { isAutoRotationEnabled: false }); + + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Old Name', + isAutoRotationEnabled: false, + }), + ); + }); + }); + + describe('remove', () => { + const subWithInbounds: Subscription = { + id: 'test-id', + name: 'Test', + uuid: 'uuid', + isEnabled: true, + isAutoRotationEnabled: true, + inboundsConfig: [], + inbounds: [ + { + id: '1', + xuiId: 101, + port: 443, + protocol: 'vless', + remark: 'test', + link: 'link', + subscription: null as any, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2', + xuiId: 102, + port: 8443, + protocol: 'vmess', + remark: 'test2', + link: 'link2', + subscription: null as any, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('должен удалить инбаунды из 3x-ui перед удалением подписки', async () => { + mockSubRepo.findOne.mockResolvedValue(subWithInbounds); + mockSubRepo.remove.mockResolvedValue(undefined); + + await service.remove('test-id'); + + expect(xuiService.deleteInbound).toHaveBeenCalledWith(101); + expect(xuiService.deleteInbound).toHaveBeenCalledWith(102); + expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds); + }); + + it('должен удалить подписку без инбаундов', async () => { + const subWithoutInbounds: Subscription = { + ...subWithInbounds, + inbounds: [], + }; + + mockSubRepo.findOne.mockResolvedValue(subWithoutInbounds); + mockSubRepo.remove.mockResolvedValue(undefined); + + await service.remove('test-id'); + + expect(xuiService.deleteInbound).not.toHaveBeenCalled(); + expect(subRepo.remove).toHaveBeenCalledWith(subWithoutInbounds); + }); + + it('должен вернуть undefined, если подписка не найдена', async () => { + mockSubRepo.findOne.mockResolvedValue(null); + + const result = await service.remove('non-existent-id'); + + expect(result).toBeUndefined(); + expect(subRepo.remove).not.toHaveBeenCalled(); + expect(xuiService.deleteInbound).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/tunnels/ssh.service.spec.ts b/server/test/tunnels/ssh.service.spec.ts new file mode 100644 index 0000000..2551574 --- /dev/null +++ b/server/test/tunnels/ssh.service.spec.ts @@ -0,0 +1,218 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SshService } from 'src/tunnels/ssh.service'; + +// Мокируем ssh2 перед импортом сервиса +const mockStreamOn = jest.fn(); +const mockStreamStderrOn = jest.fn(); +const mockStream = { + on: mockStreamOn, + stderr: { on: mockStreamStderrOn }, +}; + +const mockConnOn = jest.fn(); +const mockConnExec = jest.fn(); +const mockConnConnect = jest.fn(); +const mockConnEnd = jest.fn(); +const mockConn = { + on: mockConnOn, + exec: mockConnExec, + connect: mockConnConnect, + end: mockConnEnd, +}; + +jest.mock('ssh2', () => ({ + Client: jest.fn(() => mockConn), +})); + +describe('SshService', () => { + let service: SshService; + let readyCallback: (() => void) | null = null; + let errorCallback: ((err: Error) => void) | null = null; + let streamCloseCallback: ((code: number, signal: unknown) => void) | null = + null; + let streamDataCallback: ((data: Buffer) => void) | null = null; + let streamStderrDataCallback: ((data: Buffer) => void) | null = null; + + beforeEach(async () => { + // Сброс всех моков + jest.clearAllMocks(); + readyCallback = null; + errorCallback = null; + streamCloseCallback = null; + streamDataCallback = null; + streamStderrDataCallback = null; + + // Настройка mockConn.on для сохранения callback'ов + mockConnOn.mockImplementation((event: string, cb: () => void) => { + if (event === 'ready') readyCallback = cb; + if (event === 'error') errorCallback = cb; + return mockConn; + }); + + // Настройка mockConn.exec + mockConnExec.mockImplementation( + (_command: string, cb: (err: Error | null, stream: unknown) => void) => { + cb(null, mockStream); + return mockStream; + }, + ); + + // Настройка stream.on + mockStreamOn.mockImplementation( + (event: string, cb: (...args: unknown[]) => void) => { + if (event === 'close') + streamCloseCallback = cb as (code: number, signal: unknown) => void; + if (event === 'data') streamDataCallback = cb as (data: Buffer) => void; + return mockStream; + }, + ); + + // Настройка stream.stderr.on + mockStreamStderrOn.mockImplementation( + (event: string, cb: (data: Buffer) => void) => { + if (event === 'data') streamStderrDataCallback = cb; + return { on: mockStreamStderrOn }; + }, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [SshService], + }).compile(); + + service = module.get(SshService); + }); + + describe('executeCommand', () => { + const config = { + host: '192.168.1.100', + port: 22, + username: 'root', + password: 'password123', + }; + + const command = 'echo "test"'; + + it('должен выполнить команду успешно', async () => { + const connectPromise = service.executeCommand(config, command); + + // Симулируем успешное подключение + readyCallback(); + + // Симулируем получение данных + streamDataCallback(Buffer.from('test output\n')); + + // Симулируем завершение команды с кодом 0 + streamCloseCallback(0, null); + + const result = await connectPromise; + + expect(result).toBe('test output\n'); + expect(mockConnConnect).toHaveBeenCalledWith( + expect.objectContaining({ + host: '192.168.1.100', + port: 22, + username: 'root', + password: 'password123', + readyTimeout: 20000, + }), + ); + }); + + it('должен выполнить команду с privateKey', async () => { + const configWithKey = { + ...config, + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----', + }; + + const connectPromise = service.executeCommand(configWithKey, command); + + readyCallback(); + streamDataCallback(Buffer.from('success')); + streamCloseCallback(0, null); + + await connectPromise; + + expect(mockConnConnect).toHaveBeenCalledWith( + expect.objectContaining({ + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----', + }), + ); + }); + + it('должен обработать ошибку подключения', async () => { + const error = new Error('Connection refused'); + + const connectPromise = service.executeCommand(config, command); + + // Симулируем ошибку подключения + errorCallback(error); + + await expect(connectPromise).rejects.toThrow('Connection refused'); + }); + + it('должен обработать ошибку выполнения команды', async () => { + const execError = new Error('Command not found'); + + mockConnExec.mockImplementationOnce( + (_command: string, cb: (err: Error | null) => void) => { + cb(execError, null); + }, + ); + + const connectPromise = service.executeCommand(config, command); + + readyCallback(); + + await expect(connectPromise).rejects.toThrow('Command not found'); + }); + + it('должен обработать ненулевой код выхода', async () => { + const connectPromise = service.executeCommand(config, command); + + readyCallback(); + streamDataCallback(Buffer.from('error output')); + streamCloseCallback(1, null); + + await expect(connectPromise).rejects.toThrow('Exit code 1'); + }); + + it('должен собрать вывод из stderr', async () => { + const connectPromise = service.executeCommand(config, command); + + readyCallback(); + streamDataCallback(Buffer.from('stdout')); + streamStderrDataCallback(Buffer.from('stderr')); + streamCloseCallback(0, null); + + const result = await connectPromise; + + expect(result).toContain('stdout'); + expect(result).toContain('stderr'); + }); + + it('должен обработать пустой вывод', async () => { + const connectPromise = service.executeCommand(config, command); + + readyCallback(); + streamCloseCallback(0, null); + + const result = await connectPromise; + + expect(result).toBe(''); + }); + + it('должен собрать вывод из нескольких чанков', async () => { + const connectPromise = service.executeCommand(config, command); + + readyCallback(); + streamDataCallback(Buffer.from('chunk1')); + streamDataCallback(Buffer.from('chunk2')); + streamDataCallback(Buffer.from('chunk3')); + streamCloseCallback(0, null); + + const result = await connectPromise; + + expect(result).toBe('chunk1chunk2chunk3'); + }); + }); +}); diff --git a/server/test/tunnels/tunnels.controller.spec.ts b/server/test/tunnels/tunnels.controller.spec.ts new file mode 100644 index 0000000..0990727 --- /dev/null +++ b/server/test/tunnels/tunnels.controller.spec.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Test, TestingModule } from '@nestjs/testing'; +import { TunnelsController } from 'src/tunnels/tunnels.controller'; +import { TunnelsService } from 'src/tunnels/tunnels.service'; + +describe('TunnelsController', () => { + let controller: TunnelsController; + let tunnelsService: TunnelsService; + + const mockTunnelsService = { + create: jest.fn(), + findAll: jest.fn(), + remove: jest.fn(), + installScript: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TunnelsController], + providers: [ + { + provide: TunnelsService, + useValue: mockTunnelsService, + }, + ], + }).compile(); + + controller = module.get(TunnelsController); + tunnelsService = module.get(TunnelsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('должен создать туннель', async () => { + const dto = { ip: '192.168.1.1', sshPort: 22 }; + const mockTunnel = { id: 1, ...dto }; + + mockTunnelsService.create.mockResolvedValue(mockTunnel); + + const result = await controller.create(dto); + + expect(result).toEqual(mockTunnel); + expect(tunnelsService.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('findAll', () => { + it('должен вернуть все туннели', async () => { + const mockTunnels = [ + { id: 1, ip: '192.168.1.1' }, + { id: 2, ip: '192.168.1.2' }, + ]; + + mockTunnelsService.findAll.mockResolvedValue(mockTunnels); + + const result = await controller.findAll(); + + expect(result).toEqual(mockTunnels); + expect(tunnelsService.findAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('install', () => { + it('должен установить скрипт', async () => { + const mockResult = { success: true, output: 'done' }; + + mockTunnelsService.installScript.mockResolvedValue(mockResult); + + const result = await controller.install('1'); + + expect(result).toEqual(mockResult); + expect(tunnelsService.installScript).toHaveBeenCalledWith(1); + }); + }); + + describe('remove', () => { + it('должен удалить туннель', async () => { + mockTunnelsService.remove.mockResolvedValue(undefined); + + await controller.remove('1'); + + expect(tunnelsService.remove).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/server/test/tunnels/tunnels.service.spec.ts b/server/test/tunnels/tunnels.service.spec.ts new file mode 100644 index 0000000..ccdfa19 --- /dev/null +++ b/server/test/tunnels/tunnels.service.spec.ts @@ -0,0 +1,205 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { HttpException } from '@nestjs/common'; +import { TunnelsService } from 'src/tunnels/tunnels.service'; +import { Tunnel } from 'src/tunnels/entities/tunnel.entity'; +import { SshService } from 'src/tunnels/ssh.service'; +import { Setting } from 'src/settings/entities/setting.entity'; + +describe('TunnelsService', () => { + let service: TunnelsService; + let tunnelRepo: Repository; + let sshService: SshService; + + const mockTunnelRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(), + })), + }; + + const mockSettingRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockSshService = { + executeCommand: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TunnelsService, + { + provide: getRepositoryToken(Tunnel), + useValue: mockTunnelRepo, + }, + { + provide: getRepositoryToken(Setting), + useValue: mockSettingRepo, + }, + { + provide: SshService, + useValue: mockSshService, + }, + ], + }).compile(); + + service = module.get(TunnelsService); + tunnelRepo = module.get>(getRepositoryToken(Tunnel)); + settingRepo = module.get>(getRepositoryToken(Setting)); + sshService = module.get(SshService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('должен создать туннель', async () => { + const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' }; + const mockTunnel = { id: 1, ...dto }; + + mockTunnelRepo.create.mockReturnValue(mockTunnel); + mockTunnelRepo.save.mockResolvedValue(mockTunnel); + + const result = await service.create(dto); + + expect(result).toEqual(mockTunnel); + expect(tunnelRepo.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('findAll', () => { + it('должен вернуть все туннели', async () => { + const mockTunnels = [ + { id: 1, ip: '192.168.1.1' }, + { id: 2, ip: '192.168.1.2' }, + ]; + + mockTunnelRepo.find.mockResolvedValue(mockTunnels); + + const result = await service.findAll(); + + expect(result).toEqual(mockTunnels); + expect(tunnelRepo.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('remove', () => { + it('должен удалить туннель по ID', async () => { + mockTunnelRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.remove(1); + + expect(tunnelRepo.delete).toHaveBeenCalledWith(1); + }); + }); + + describe('installScript', () => { + const mockTunnel = { + id: 1, + ip: '192.168.1.100', + sshPort: 22, + username: 'root', + password: 'password123', + privateKey: null, + isInstalled: false, + }; + + it('должен установить скрипт перенаправления', async () => { + mockTunnelRepo.createQueryBuilder.mockReturnValue({ + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockTunnel), + }); + + mockSettingRepo.findOne.mockResolvedValue({ + key: 'xui_ip', + value: '10.0.0.1', + }); + mockSshService.executeCommand.mockResolvedValue( + 'Script executed successfully', + ); + mockTunnelRepo.save.mockResolvedValue({ + ...mockTunnel, + isInstalled: true, + }); + + const result = await service.installScript(1); + + expect(result).toEqual({ + success: true, + output: 'Script executed successfully', + }); + expect(sshService.executeCommand).toHaveBeenCalled(); + expect(tunnelRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ isInstalled: true }), + ); + }); + + it('должен бросить HttpException, если туннель не найден', async () => { + mockTunnelRepo.createQueryBuilder.mockReturnValue({ + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }); + + await expect(service.installScript(999)).rejects.toThrow(HttpException); + + await expect(service.installScript(999)).rejects.toThrow( + 'Tunnel not found', + ); + }); + + it('должен бросить HttpException, если xui_host не настроен', async () => { + mockTunnelRepo.createQueryBuilder.mockReturnValue({ + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockTunnel), + }); + + mockSettingRepo.findOne.mockResolvedValue(null); + + await expect(service.installScript(1)).rejects.toThrow(HttpException); + + await expect(service.installScript(1)).rejects.toThrow( + 'В настройках (Settings) не сохранен Host/IP основного сервера', + ); + }); + + it('должен бросить HttpException при ошибке SSH', async () => { + mockTunnelRepo.createQueryBuilder.mockReturnValue({ + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockTunnel), + }); + + mockSettingRepo.findOne.mockResolvedValue({ + key: 'xui_ip', + value: '10.0.0.1', + }); + mockSshService.executeCommand.mockRejectedValue( + new Error('SSH connection failed'), + ); + + await expect(service.installScript(1)).rejects.toThrow(HttpException); + + await expect(service.installScript(1)).rejects.toThrow( + 'Ошибка установки: SSH connection failed', + ); + }); + }); +}); diff --git a/server/test/xui/xui.service.spec.ts b/server/test/xui/xui.service.spec.ts new file mode 100644 index 0000000..d31c740 --- /dev/null +++ b/server/test/xui/xui.service.spec.ts @@ -0,0 +1,198 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { XuiService } from 'src/xui/xui.service'; +import { Setting } from 'src/settings/entities/setting.entity'; +import { SessionService } from 'src/session/session.service'; +import axios from 'axios'; + +jest.mock('axios'); + +describe('XuiService', () => { + let service: XuiService; + + const mockSettingsRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockSessionService = { + getCookie: jest.fn(), + setFromHeaders: jest.fn(), + }; + + const mockAxiosInstance = { + get: jest.fn(), + post: jest.fn(), + defaults: { baseURL: '' }, + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + }; + + beforeEach(async () => { + (axios.create as jest.Mock).mockReturnValue(mockAxiosInstance); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + XuiService, + { + provide: getRepositoryToken(Setting), + useValue: mockSettingsRepo, + }, + { + provide: SessionService, + useValue: mockSessionService, + }, + ], + }).compile(); + + service = module.get(XuiService); + settingsRepo = module.get>(getRepositoryToken(Setting)); + sessionService = module.get(SessionService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('login', () => { + it('должен вернуть false, если настройки не заполнены', async () => { + mockSettingsRepo.find.mockResolvedValue([]); + + const result = await service.login(); + + expect(result).toBe(false); + }); + + it('должен вернуть true при успешном логине', async () => { + mockSettingsRepo.find.mockResolvedValue([ + { key: 'xui_url', value: 'http://localhost:3000' }, + { key: 'xui_login', value: 'admin' }, + { key: 'xui_password', value: 'password' }, + ]); + + mockAxiosInstance.post.mockResolvedValue({ + headers: { + 'set-cookie': ['session=abc123'], + }, + }); + + const result = await service.login(); + + expect(result).toBe(true); + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/login', { + username: 'admin', + password: 'password', + }); + }); + + it('должен вернуть false при ошибке логина', async () => { + mockSettingsRepo.find.mockResolvedValue([ + { key: 'xui_url', value: 'http://localhost:3000' }, + { key: 'xui_login', value: 'admin' }, + { key: 'xui_password', value: 'password' }, + ]); + + mockAxiosInstance.post.mockRejectedValue(new Error('Network error')); + + const result = await service.login(); + + expect(result).toBe(false); + }); + + it('должен вернуть false, если нет cookie в ответе', async () => { + mockSettingsRepo.find.mockResolvedValue([ + { key: 'xui_url', value: 'http://localhost:3000' }, + { key: 'xui_login', value: 'admin' }, + { key: 'xui_password', value: 'password' }, + ]); + + mockAxiosInstance.post.mockResolvedValue({ + headers: {}, + }); + + const result = await service.login(); + + expect(result).toBe(false); + }); + }); + + describe('checkConnection', () => { + it('должен вернуть false при ошибке подключения', async () => { + mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed')); + + const result = await service.checkConnection( + 'http://localhost:3000', + 'admin', + 'password', + ); + + expect(result).toBe(false); + }); + }); + + describe('addInbound', () => { + it('должен вернуть null при ошибке', async () => { + mockAxiosInstance.post.mockRejectedValue(new Error('API error')); + + const result = await service.addInbound( + {} as unknown as { port: number }, + ); + + expect(result).toBeNull(); + }); + }); + + describe('deleteInbound', () => { + it('должен удалить инбаунд', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: { success: true } }); + + await service.deleteInbound(101); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/panel/api/inbounds/del/101', + ); + }); + + it('должен обработать ошибку удаления', async () => { + mockAxiosInstance.post.mockRejectedValue(new Error('Not found')); + + await service.deleteInbound(999); + + expect(mockAxiosInstance.post).toHaveBeenCalled(); + }); + }); + + describe('getNewX25519Cert', () => { + it('должен получить Reality ключи', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { + success: true, + obj: { + publicKey: 'pub-key', + privateKey: 'priv-key', + }, + }, + }); + + const result = await service.getNewX25519Cert(); + + expect(result).toEqual({ + publicKey: 'pub-key', + privateKey: 'priv-key', + }); + }); + + it('должен вернуть null при ошибке', async () => { + mockAxiosInstance.get.mockRejectedValue(new Error('API error')); + + const result = await service.getNewX25519Cert(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/server/tsconfig.spec.json b/server/tsconfig.spec.json new file mode 100644 index 0000000..2aa9777 --- /dev/null +++ b/server/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/test", + "types": ["jest", "node"] + }, + "include": ["test/**/*.ts", "src/**/*.ts"] +}