This commit is contained in:
Den Piligrim
2026-05-21 22:58:29 +03:00
parent 962544820f
commit 26f3bb2934
48 changed files with 2877 additions and 1170 deletions
@@ -270,6 +270,50 @@ describe('InboundBuilderService', () => {
});
});
describe('buildHysteria2Inbound', () => {
it('creates 3x-ui hysteria v2 inbound with certificate paths', () => {
const result = service.buildHysteria2Inbound({
port: 34443,
uuid: 'test-auth',
sni: 'oil.3dp-manager.com',
});
expect(result).toMatchObject({
enable: true,
listen: '0.0.0.0',
port: 34443,
protocol: 'hysteria',
tag: 'inbound-34443',
});
const settings = JSON.parse(result.settings);
const streamSettings = JSON.parse(result.streamSettings);
expect(settings.clients[0].auth).toBe('test-auth');
expect(settings.version).toBe(2);
expect(streamSettings.network).toBe('hysteria');
expect(streamSettings.hysteriaSettings.version).toBe(2);
expect(streamSettings.finalmask.udp[0].type).toBe('salamander');
expect(streamSettings.tlsSettings.certificates[0].certificateFile).toBe(
'/etc/letsencrypt/live/oil.3dp-manager.com/fullchain.pem',
);
expect(streamSettings.tlsSettings.certificates[0].keyFile).toBe(
'/etc/letsencrypt/live/oil.3dp-manager.com/privkey.pem',
);
const link = service.buildInboundLink(
result as any,
'relay.example.com',
'fallback-auth',
'%F0%9F%92%AF',
);
expect(link).toContain('hy2://test-auth@relay.example.com:34443/');
expect(link).toContain('sni=oil.3dp-manager.com');
expect(link).toContain('obfs=salamander');
expect(link).toContain('obfs-password=abcd1234');
});
});
describe('buildInboundLink', () => {
const baseInbound = {
protocol: 'vless',
+102
View File
@@ -0,0 +1,102 @@
import { Repository } from 'typeorm';
import { NodesService } from 'src/nodes/nodes.service';
import { Node } from 'src/nodes/entities/node.entity';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
import { Inbound } from 'src/inbounds/entities/inbound.entity';
import { XuiService } from 'src/xui/xui.service';
describe('NodesService', () => {
const createNodeRepo = (getOne: jest.Mock) => ({
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne,
})),
count: jest.fn(),
remove: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
});
const createService = (nodeRepo: ReturnType<typeof createNodeRepo>) => {
const subscriptionsRepo = {
createQueryBuilder: jest.fn(() => ({
delete: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({}),
})),
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
};
const tunnelsRepo = {
createQueryBuilder: jest.fn(() => ({
delete: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({}),
})),
delete: jest.fn(),
};
const inboundsRepo = {
find: jest.fn().mockResolvedValue([]),
delete: jest.fn(),
};
const xuiService = {
deleteInbound: jest.fn().mockResolvedValue(true),
};
return {
service: new NodesService(
nodeRepo as unknown as Repository<Node>,
subscriptionsRepo as unknown as Repository<Subscription>,
tunnelsRepo as unknown as Repository<Tunnel>,
inboundsRepo as unknown as Repository<Inbound>,
xuiService as unknown as XuiService,
),
subscriptionsRepo,
tunnelsRepo,
inboundsRepo,
xuiService,
};
};
it('deletes the main node and makes the next node main', async () => {
const mainNode = { id: 'main', isMain: true } as Node;
const nextNode = { id: 'next', isMain: false } as Node;
const getOne = jest
.fn()
.mockResolvedValueOnce(mainNode)
.mockResolvedValueOnce(null);
const nodeRepo = createNodeRepo(getOne);
nodeRepo.count.mockResolvedValue(2);
nodeRepo.findOne.mockResolvedValue(nextNode);
nodeRepo.save.mockResolvedValue(nextNode);
const { service } = createService(nodeRepo);
const result = await service.remove('main');
expect(result).toEqual({ success: true });
expect(nodeRepo.remove).toHaveBeenCalledWith(mainNode);
expect(nodeRepo.findOne).toHaveBeenCalledWith({
where: {},
order: { createdAt: 'DESC' },
});
expect(nextNode.isMain).toBe(true);
expect(nodeRepo.save).toHaveBeenCalledWith(nextNode);
});
it('uses node credentials when deleting node inbounds', async () => {
const mainNode = { id: 'main', isMain: true } as Node;
const getOne = jest
.fn()
.mockResolvedValueOnce(mainNode)
.mockResolvedValueOnce(null);
const nodeRepo = createNodeRepo(getOne);
nodeRepo.count.mockResolvedValue(1);
nodeRepo.findOne.mockResolvedValue(null);
const { service, inboundsRepo, xuiService } = createService(nodeRepo);
inboundsRepo.find.mockResolvedValue([{ id: 1, xuiId: 101, nodeId: 'main' }]);
await service.remove('main');
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, mainNode);
});
});
+77 -4
View File
@@ -14,6 +14,8 @@ 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', () => ({
@@ -64,6 +66,19 @@ describe('RotationService', () => {
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(),
@@ -79,6 +94,7 @@ describe('RotationService', () => {
buildVmessTcp: jest.fn(),
buildShadowsocksTcp: jest.fn(),
buildTrojanRealityTcp: jest.fn(),
buildHysteria2Inbound: jest.fn(),
buildHysteria2Link: jest.fn(),
buildInboundLink: jest.fn(),
};
@@ -103,6 +119,14 @@ describe('RotationService', () => {
provide: getRepositoryToken(Setting),
useValue: mockSettingRepo,
},
{
provide: getRepositoryToken(Node),
useValue: mockNodeRepo,
},
{
provide: getRepositoryToken(Tunnel),
useValue: mockTunnelRepo,
},
{
provide: XuiService,
useValue: mockXuiService,
@@ -129,6 +153,10 @@ describe('RotationService', () => {
jest.clearAllMocks();
});
beforeEach(() => {
mockXuiService.deleteInbound.mockResolvedValue(true);
});
describe('onModuleInit', () => {
it('должен инициализировать настройки по умолчанию', async () => {
mockSettingRepo.findOne.mockResolvedValue(null);
@@ -437,7 +465,7 @@ describe('RotationService', () => {
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
expect(inboundRepo.delete).toHaveBeenCalled();
});
@@ -468,6 +496,7 @@ describe('RotationService', () => {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
node: { id: 'node-1', domain: 'node.example.com' },
inbounds: [],
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
};
@@ -475,15 +504,59 @@ describe('RotationService', () => {
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockInboundBuilder.buildHysteria2Link.mockReturnValue('hy2://link');
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).not.toHaveBeenCalled();
expect(inboundBuilder.buildHysteria2Link).toHaveBeenCalled();
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 () => {
@@ -8,6 +8,8 @@ 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';
import { Node } from 'src/nodes/entities/node.entity';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
@@ -23,6 +25,19 @@ describe('SubscriptionsService', () => {
remove: 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 = {
deleteInbound: jest.fn(),
};
@@ -35,6 +50,14 @@ describe('SubscriptionsService', () => {
provide: getRepositoryToken(Subscription),
useValue: mockSubRepo,
},
{
provide: getRepositoryToken(Node),
useValue: mockNodeRepo,
},
{
provide: getRepositoryToken(Tunnel),
useValue: mockTunnelRepo,
},
{
provide: XuiService,
useValue: mockXuiService,
@@ -47,6 +70,7 @@ describe('SubscriptionsService', () => {
getRepositoryToken(Subscription),
);
xuiService = module.get<XuiService>(XuiService);
mockXuiService.deleteInbound.mockResolvedValue(true);
});
afterEach(() => {
@@ -86,7 +110,7 @@ describe('SubscriptionsService', () => {
expect(result).toEqual(mockSubs);
expect(subRepo.find).toHaveBeenCalledWith({
relations: ['inbounds'],
relations: ['inbounds', 'node', 'relayServer'],
order: { createdAt: 'DESC' },
});
});
@@ -128,6 +152,8 @@ describe('SubscriptionsService', () => {
uuid: expect.any(String),
inboundsConfig: createDto.inboundsConfig,
isAutoRotationEnabled: true,
node: null,
relayServer: null,
});
expect(subRepo.save).toHaveBeenCalledWith(mockSubscription);
expect(result).toEqual(mockSubscription);
@@ -197,7 +223,7 @@ describe('SubscriptionsService', () => {
expect(subRepo.findOne).toHaveBeenCalledWith({
where: { id: 'test-id' },
relations: ['inbounds'],
relations: ['inbounds', 'node', 'relayServer'],
});
expect(subRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Name' }),
@@ -329,8 +355,8 @@ describe('SubscriptionsService', () => {
await service.remove('test-id');
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102, undefined);
expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds);
});
@@ -82,7 +82,7 @@ describe('TunnelsController', () => {
await controller.remove('1');
expect(tunnelsService.remove).toHaveBeenCalledWith(1);
expect(tunnelsService.remove).toHaveBeenCalledWith(1, false);
});
});
});
+30 -4
View File
@@ -8,6 +8,8 @@ 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';
import { Node } from 'src/nodes/entities/node.entity';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
describe('TunnelsService', () => {
let service: TunnelsService;
@@ -34,6 +36,15 @@ describe('TunnelsService', () => {
save: jest.fn(),
};
const mockNodeRepo = {
findOne: jest.fn(),
};
const mockSubscriptionRepo = {
find: jest.fn(),
save: jest.fn(),
};
const mockSshService = {
executeCommand: jest.fn(),
};
@@ -50,6 +61,14 @@ describe('TunnelsService', () => {
provide: getRepositoryToken(Setting),
useValue: mockSettingRepo,
},
{
provide: getRepositoryToken(Node),
useValue: mockNodeRepo,
},
{
provide: getRepositoryToken(Subscription),
useValue: mockSubscriptionRepo,
},
{
provide: SshService,
useValue: mockSshService,
@@ -64,21 +83,27 @@ describe('TunnelsService', () => {
});
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe('create', () => {
it('должен создать туннель', async () => {
const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' };
const mockTunnel = { id: 1, ...dto };
const node = { id: 'node-1', isMain: true };
const mockTunnel = { id: 1, ...dto, node, nodeId: node.id };
mockNodeRepo.findOne.mockResolvedValue(node);
mockTunnelRepo.create.mockReturnValue(mockTunnel);
mockTunnelRepo.save.mockResolvedValue(mockTunnel);
const result = await service.create(dto);
expect(result).toEqual(mockTunnel);
expect(tunnelRepo.create).toHaveBeenCalledWith(dto);
expect(tunnelRepo.create).toHaveBeenCalledWith({
...dto,
node,
nodeId: node.id,
});
});
});
@@ -94,12 +119,13 @@ describe('TunnelsService', () => {
const result = await service.findAll();
expect(result).toEqual(mockTunnels);
expect(tunnelRepo.find).toHaveBeenCalledTimes(1);
expect(tunnelRepo.find).toHaveBeenCalledWith({ relations: ['node'] });
});
});
describe('remove', () => {
it('должен удалить туннель по ID', async () => {
mockSubscriptionRepo.find.mockResolvedValue([]);
mockTunnelRepo.delete.mockResolvedValue({ affected: 1 });
await service.remove(1);
+32 -4
View File
@@ -56,7 +56,7 @@ describe('XuiService', () => {
});
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe('login', () => {
@@ -149,7 +149,16 @@ describe('XuiService', () => {
describe('deleteInbound', () => {
it('должен удалить инбаунд', async () => {
mockAxiosInstance.post.mockResolvedValue({ data: { success: true } });
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post
.mockResolvedValueOnce({
headers: { 'set-cookie': ['session=abc123'] },
})
.mockResolvedValueOnce({ data: { success: true } });
await service.deleteInbound(101);
@@ -159,16 +168,35 @@ describe('XuiService', () => {
});
it('должен обработать ошибку удаления', async () => {
mockAxiosInstance.post.mockRejectedValue(new Error('Not found'));
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post
.mockResolvedValueOnce({
headers: { 'set-cookie': ['session=abc123'] },
})
.mockRejectedValueOnce(new Error('Not found'));
await service.deleteInbound(999);
expect(mockAxiosInstance.post).toHaveBeenCalled();
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/panel/api/inbounds/del/999',
);
});
});
describe('getNewX25519Cert', () => {
it('должен получить Reality ключи', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post.mockResolvedValueOnce({
headers: { 'set-cookie': ['session=abc123'] },
});
mockAxiosInstance.get.mockResolvedValue({
data: {
success: true,