149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import request from 'supertest';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import type { UpdateSystemInput } from '../src/shared/contracts';
|
|
import { createApp } from './app';
|
|
import type { RuntimeSnapshot } from './lib/config';
|
|
import type { RuntimeController } from './lib/runtime';
|
|
import { StateStore } from './lib/store';
|
|
|
|
class FakeRuntime implements RuntimeController {
|
|
private status: RuntimeSnapshot = {
|
|
status: 'idle' as const,
|
|
pid: null,
|
|
startedAt: null,
|
|
lastError: null,
|
|
};
|
|
|
|
getSnapshot() {
|
|
return { ...this.status };
|
|
}
|
|
|
|
async start() {
|
|
this.status = {
|
|
status: 'live',
|
|
pid: 999,
|
|
startedAt: new Date('2026-04-01T00:00:00.000Z').toISOString(),
|
|
lastError: null,
|
|
};
|
|
return this.getSnapshot();
|
|
}
|
|
|
|
async restart() {
|
|
return this.start();
|
|
}
|
|
|
|
async reload() {
|
|
return this.getSnapshot();
|
|
}
|
|
}
|
|
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe('panel api', () => {
|
|
it('rejects user creation against a non-assignable service', async () => {
|
|
const app = await createTestApp();
|
|
const response = await request(app).post('/api/users').send({
|
|
username: 'bad-admin-user',
|
|
password: 'secret123',
|
|
serviceId: 'admin',
|
|
quotaMb: 100,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toMatch(/enabled assignable/i);
|
|
});
|
|
|
|
it('pauses and deletes a user through the api', async () => {
|
|
const app = await createTestApp();
|
|
const initial = await request(app).get('/api/state');
|
|
const userId = initial.body.userRecords[0].id;
|
|
const username = initial.body.userRecords[0].username;
|
|
|
|
const paused = await request(app).post(`/api/users/${userId}/pause`);
|
|
expect(paused.status).toBe(200);
|
|
expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true);
|
|
|
|
const removed = await request(app).delete(`/api/users/${userId}`);
|
|
expect(removed.status).toBe(200);
|
|
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('rejects system updates when two services reuse the same port', async () => {
|
|
const app = await createTestApp();
|
|
const initial = await request(app).get('/api/state');
|
|
const system = createSystemPayload(initial.body);
|
|
|
|
system.services[1].port = system.services[0].port;
|
|
|
|
const response = await request(app).put('/api/system').send(system);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toMatch(/cannot share port/i);
|
|
});
|
|
|
|
it('rejects system updates that strand existing users on a disabled service', async () => {
|
|
const app = await createTestApp();
|
|
const initial = await request(app).get('/api/state');
|
|
const system = createSystemPayload(initial.body);
|
|
|
|
system.services = system.services.map((service) =>
|
|
service.id === 'socks-main' ? { ...service, enabled: false } : service,
|
|
);
|
|
|
|
const response = await request(app).put('/api/system').send(system);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toMatch(/enabled assignable service/i);
|
|
expect(response.body.error).toMatch(/night-shift/i);
|
|
});
|
|
|
|
it('updates system settings and regenerates the rendered config', async () => {
|
|
const app = await createTestApp();
|
|
const initial = await request(app).get('/api/state');
|
|
const system = createSystemPayload(initial.body);
|
|
|
|
system.publicHost = 'ops-gateway.example.net';
|
|
system.services = system.services.map((service) =>
|
|
service.id === 'socks-main' ? { ...service, port: 1180 } : service,
|
|
);
|
|
|
|
const response = await request(app).put('/api/system').send(system);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.system.publicHost).toBe('ops-gateway.example.net');
|
|
expect(response.body.system.services.find((service: { id: string }) => service.id === 'socks-main').port).toBe(
|
|
1180,
|
|
);
|
|
expect(response.body.system.previewConfig).toContain('socks -p1180 -u2');
|
|
expect(response.body.service.lastEvent).toBe('System configuration updated from panel');
|
|
});
|
|
});
|
|
|
|
async function createTestApp() {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-ui-'));
|
|
cleanupDirs.push(dir);
|
|
|
|
const runtime = new FakeRuntime();
|
|
const store = new StateStore(path.join(dir, 'state', 'panel-state.json'));
|
|
|
|
return createApp({
|
|
store,
|
|
runtime,
|
|
runtimeRootDir: dir,
|
|
});
|
|
}
|
|
|
|
function createSystemPayload(body: { system: Record<string, unknown> }): UpdateSystemInput {
|
|
const { previewConfig: _previewConfig, ...system } = body.system;
|
|
return structuredClone(system) as unknown as UpdateSystemInput;
|
|
}
|