624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
/* 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';
|
|
import { Node } from 'src/nodes/entities/node.entity';
|
|
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
|
|
|
// Mock @nestjs/schedule для тестирования Cron
|
|
jest.mock('@nestjs/schedule', () => ({
|
|
Cron: () => () => {},
|
|
CronExpression: { EVERY_MINUTE: '* * * * *' },
|
|
}));
|
|
|
|
describe('RotationService', () => {
|
|
let service: RotationService;
|
|
let _subRepo: Repository<Subscription>;
|
|
let inboundRepo: Repository<Inbound>;
|
|
let _domainRepo: Repository<Domain>;
|
|
let settingRepo: Repository<Setting>;
|
|
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 mockNodeRepo = {
|
|
findOne: jest.fn(),
|
|
createQueryBuilder: jest.fn(() => ({
|
|
addSelect: jest.fn().mockReturnThis(),
|
|
where: jest.fn().mockReturnThis(),
|
|
getOne: jest.fn(),
|
|
})),
|
|
};
|
|
|
|
const mockTunnelRepo = {
|
|
findOne: 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(),
|
|
buildHysteria2Inbound: 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: getRepositoryToken(Node),
|
|
useValue: mockNodeRepo,
|
|
},
|
|
{
|
|
provide: getRepositoryToken(Tunnel),
|
|
useValue: mockTunnelRepo,
|
|
},
|
|
{
|
|
provide: XuiService,
|
|
useValue: mockXuiService,
|
|
},
|
|
{
|
|
provide: InboundBuilderService,
|
|
useValue: mockInboundBuilder,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<RotationService>(RotationService);
|
|
_subRepo = module.get<Repository<Subscription>>(
|
|
getRepositoryToken(Subscription),
|
|
);
|
|
inboundRepo = module.get<Repository<Inbound>>(getRepositoryToken(Inbound));
|
|
_domainRepo = module.get<Repository<Domain>>(getRepositoryToken(Domain));
|
|
settingRepo = module.get<Repository<Setting>>(getRepositoryToken(Setting));
|
|
xuiService = module.get<XuiService>(XuiService);
|
|
inboundBuilder = module.get<InboundBuilderService>(InboundBuilderService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockXuiService.deleteInbound.mockResolvedValue(true);
|
|
});
|
|
|
|
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, undefined);
|
|
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,
|
|
node: { id: 'node-1', domain: 'node.example.com' },
|
|
inbounds: [],
|
|
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
|
};
|
|
|
|
mockDomainRepo.find.mockResolvedValue([
|
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
|
]);
|
|
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
|
privateKey: 'key',
|
|
publicKey: 'pub',
|
|
});
|
|
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
|
protocol: 'hysteria2',
|
|
remark: 'hysteria2-udp',
|
|
settings: '{"clients":[{"password":"uuid"}]}',
|
|
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
|
sniffing: '{}',
|
|
});
|
|
mockXuiService.addInbound.mockResolvedValue(101);
|
|
mockInboundBuilder.buildInboundLink.mockReturnValue('hy2://link');
|
|
mockInboundRepo.save.mockResolvedValue({});
|
|
|
|
await (service as any).rotateSubscription(mockSub, [
|
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
|
]);
|
|
|
|
expect(xuiService.addInbound).toHaveBeenCalled();
|
|
expect(inboundBuilder.buildHysteria2Inbound).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sni: 'node.example.com' }),
|
|
);
|
|
expect(inboundBuilder.buildInboundLink).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not save hysteria2 when 3x-ui does not create an inbound', async () => {
|
|
const mockSub = {
|
|
id: '1',
|
|
uuid: 'uuid-1',
|
|
isEnabled: true,
|
|
inbounds: [],
|
|
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
|
};
|
|
|
|
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
|
privateKey: 'key',
|
|
publicKey: 'pub',
|
|
});
|
|
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
|
protocol: 'hysteria2',
|
|
remark: 'hysteria2-udp',
|
|
settings: '{"clients":[{"password":"uuid"}]}',
|
|
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
|
sniffing: '{}',
|
|
});
|
|
mockXuiService.addInbound.mockResolvedValue(null);
|
|
|
|
await (service as any).rotateSubscription(mockSub, [
|
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
|
]);
|
|
|
|
expect(inboundRepo.save).not.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();
|
|
});
|
|
});
|
|
});
|