342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import App, { copyTextToClipboard } from './App';
|
|
import { fallbackDashboardSnapshot } from './data/mockDashboard';
|
|
import { MockWebSocket } from './test/setup';
|
|
|
|
async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
|
await user.type(screen.getByLabelText(/login/i), 'admin');
|
|
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
|
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
|
}
|
|
|
|
function mockClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText,
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.documentElement.dataset.theme = '';
|
|
window.sessionStorage.clear();
|
|
window.localStorage.clear();
|
|
window.history.replaceState(null, '', '/');
|
|
});
|
|
|
|
describe('App login gate', () => {
|
|
it('rejects wrong hardcoded credentials and keeps the panel locked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await user.type(screen.getByLabelText(/login/i), 'admin');
|
|
await user.type(screen.getByLabelText(/password/i), 'wrong-pass');
|
|
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
|
|
|
expect(screen.getByText(/wrong panel credentials/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole('navigation', { name: /primary/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('unlocks the panel with the configured credentials', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
|
|
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: /3proxy ui/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('restores the panel session from sessionStorage after a remount', async () => {
|
|
const user = userEvent.setup();
|
|
const firstRender = render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
|
|
|
firstRender.unmount();
|
|
render(<App />);
|
|
|
|
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('stores panel language in localStorage and restores it after a remount', async () => {
|
|
const user = userEvent.setup();
|
|
const firstRender = render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /settings/i }));
|
|
await user.selectOptions(screen.getByLabelText(/panel language/i), 'ru');
|
|
|
|
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument();
|
|
|
|
firstRender.unmount();
|
|
render(<App />);
|
|
|
|
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^настройки$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('stores panel theme in localStorage and restores it after a remount', async () => {
|
|
const user = userEvent.setup();
|
|
const firstRender = render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /settings/i }));
|
|
await user.selectOptions(screen.getByLabelText(/panel style/i), 'dark');
|
|
|
|
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
|
|
firstRender.unmount();
|
|
render(<App />);
|
|
|
|
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
});
|
|
|
|
it('keeps tab navigation in the hash and restores the active tab after remount', async () => {
|
|
const user = userEvent.setup();
|
|
const firstRender = render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
expect(window.location.hash).toBe('#users');
|
|
expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument();
|
|
|
|
firstRender.unmount();
|
|
render(<App />);
|
|
|
|
expect(window.location.hash).toBe('#users');
|
|
expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('can stop the runtime from the dashboard', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /stop/i }));
|
|
|
|
expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('shows online data in the users table and keeps proxy copy inside actions', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
expect(screen.getByRole('columnheader', { name: /online/i })).toBeInTheDocument();
|
|
expect(screen.queryByRole('columnheader', { name: /^proxy$/i })).not.toBeInTheDocument();
|
|
expect(screen.getAllByRole('button', { name: /copy proxy link/i }).length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText(/^now$/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('copies the proxy link via the Clipboard API when available', async () => {
|
|
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
|
mockClipboardWriteText(clipboardWriteText);
|
|
|
|
await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080');
|
|
|
|
expect(clipboardWriteText).toHaveBeenCalledWith('socks5://night-shift:kettle!23@edge.example.net:1080');
|
|
});
|
|
|
|
it('falls back to execCommand copy when the Clipboard API is unavailable', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
const execCommand = vi.fn(() => true);
|
|
Object.defineProperty(document, 'execCommand', {
|
|
configurable: true,
|
|
value: execCommand,
|
|
});
|
|
|
|
await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080');
|
|
|
|
expect(execCommand).toHaveBeenCalledWith('copy');
|
|
});
|
|
|
|
it('opens add-user flow in a modal and closes it on escape', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
await user.click(screen.getByRole('button', { name: /new user/i }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: /add user/i });
|
|
|
|
expect(dialog).toBeInTheDocument();
|
|
expect(String((screen.getByLabelText(/username/i) as HTMLInputElement).value)).toMatch(/^user-/i);
|
|
expect(String((screen.getByLabelText(/password/i) as HTMLInputElement).value)).toMatch(/^pw-/i);
|
|
expect(screen.getByLabelText(/service/i)).toBeInTheDocument();
|
|
expect(screen.queryByLabelText(/port/i)).not.toBeInTheDocument();
|
|
expect(within(dialog).getByText(/edge\.example\.net:1080/i)).toBeInTheDocument();
|
|
|
|
await user.keyboard('{Escape}');
|
|
|
|
expect(screen.queryByRole('dialog', { name: /add user/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('pauses and resumes a user profile from the table', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
await user.click(screen.getAllByRole('button', { name: /pause user/i })[0]);
|
|
expect(screen.getByText(/^paused$/i)).toBeInTheDocument();
|
|
expect(screen.getAllByRole('button', { name: /resume user/i })).toHaveLength(1);
|
|
|
|
await user.click(screen.getByRole('button', { name: /resume user/i }));
|
|
expect(screen.queryByText(/^paused$/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('confirms before deleting a user and removes the row after approval', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
expect(screen.getByText(/night-shift/i)).toBeInTheDocument();
|
|
|
|
await user.click(screen.getAllByRole('button', { name: /^delete user$/i })[0]);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: /delete user/i });
|
|
expect(dialog).toBeInTheDocument();
|
|
expect(within(dialog).getByText(/remove profile/i)).toBeInTheDocument();
|
|
|
|
await user.click(within(dialog).getByRole('button', { name: /delete user/i }));
|
|
|
|
expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('uses a combined service type field in settings and applies saved proxy endpoint values to the local fallback state', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
expect(screen.getByRole('heading', { name: /panel settings/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: /^services$/i })).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/proxy endpoint/i)).toBeInTheDocument();
|
|
expect(screen.queryByLabelText(/command/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText(/protocol/i)).not.toBeInTheDocument();
|
|
expect(screen.getAllByLabelText(/type/i)).toHaveLength(3);
|
|
expect(screen.queryByRole('option', { name: /admin/i })).not.toBeInTheDocument();
|
|
|
|
await user.clear(screen.getByLabelText(/proxy endpoint/i));
|
|
await user.type(screen.getByLabelText(/proxy endpoint/i), 'gw.example.net');
|
|
|
|
const firstPortInput = screen.getAllByLabelText(/port/i)[0];
|
|
await user.clear(firstPortInput);
|
|
await user.type(firstPortInput, '1180');
|
|
|
|
await user.click(screen.getByRole('button', { name: /save settings/i }));
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
expect(screen.getAllByText(/gw\.example\.net:1180/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('opens edit-user flow with existing credentials instead of generating new ones', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
await user.click(screen.getAllByRole('button', { name: /edit user/i })[0]);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: /edit user/i });
|
|
|
|
expect(within(dialog).getByLabelText(/username/i)).toHaveValue('night-shift');
|
|
expect(within(dialog).getByLabelText(/password/i)).toHaveValue('kettle!23');
|
|
|
|
await user.clear(within(dialog).getByLabelText(/username/i));
|
|
await user.type(within(dialog).getByLabelText(/username/i), 'night-shift-updated');
|
|
await user.click(within(dialog).getByRole('button', { name: /save user/i }));
|
|
|
|
expect(screen.getByText(/night-shift-updated/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole('dialog', { name: /edit user/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not overwrite dirty system settings when a websocket patch arrives', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await waitFor(() => expect(MockWebSocket.instances.length).toBeGreaterThan(0));
|
|
const socket = MockWebSocket.instances[0];
|
|
|
|
await user.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const endpointInput = screen.getByLabelText(/proxy endpoint/i);
|
|
await user.clear(endpointInput);
|
|
await user.type(endpointInput, 'draft.example.net');
|
|
|
|
socket.emitMessage({
|
|
type: 'snapshot.patch',
|
|
patch: {
|
|
system: {
|
|
...fallbackDashboardSnapshot.system,
|
|
publicHost: 'server-sync.example.net',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(screen.getByLabelText(/proxy endpoint/i)).toHaveValue('draft.example.net');
|
|
});
|
|
|
|
it('shows a connection notice and exits the panel after websocket and api retries are exhausted', async () => {
|
|
vi.useFakeTimers();
|
|
MockWebSocket.connectMode = 'close';
|
|
window.sessionStorage.setItem(
|
|
'3proxy-ui-panel-session',
|
|
JSON.stringify({
|
|
token: 'local-ui-fallback',
|
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
await vi.advanceTimersByTimeAsync(2_100);
|
|
|
|
expect(screen.getByText(/reconnecting websocket|переподключаем websocket/i)).toBeInTheDocument();
|
|
|
|
await vi.advanceTimersByTimeAsync(25_000);
|
|
|
|
expect(screen.getByRole('button', { name: /open panel/i })).toBeInTheDocument();
|
|
}, 10_000);
|
|
|
|
it('warns before deleting a service and removes linked users after confirmation', async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await loginIntoPanel(user);
|
|
await user.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
await user.click(screen.getAllByRole('button', { name: /^remove$/i })[0]);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: /delete service/i });
|
|
expect(dialog).toBeInTheDocument();
|
|
expect(within(dialog).getByText(/linked users to be removed/i)).toBeInTheDocument();
|
|
expect(within(dialog).getByText(/night-shift, ops-east/i)).toBeInTheDocument();
|
|
|
|
await user.click(within(dialog).getByRole('button', { name: /^remove$/i }));
|
|
await user.click(screen.getByRole('button', { name: /save settings/i }));
|
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
|
|
|
expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/ops-east/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|