Files
3dp-manager/server/test/rotation/rotation.service.spec.ts
T
Den Piligrim 26f3bb2934 v2.2.0
2026-05-21 22:58:29 +03:00

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();
});
});
});