import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { App } from '../app';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import type { AccountView, ServerSettings, SessionInfo } from '../types';
const flags = { consoleEnabled: false, moderationEnabled: false, claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false };
const ownerSession = (reauthenticationRequired: boolean): SessionInfo => ({
authenticated: true,
csrfToken: 'csrf',
actor: { id: 'owner-id', displayName: 'owner', roles: ['owner'], permissions: [], totpVerified: true, totpEnabled: true, reauthenticationRequired },
});
const helper: AccountView = {
id: 'helper-id', username: 'helper', role: 'helper', totpEnabled: false, disabled: false,
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null,
};
const settingsValue: ServerSettings = {
bindAddress: '127.0.0.1', port: 8765, publicBaseUrl: 'http://127.0.0.1:8765',
trustedProxyCidrs: [], idleSessionSeconds: 1800, absoluteSessionSeconds: 28800,
recentAuthenticationSeconds: 300, httpThreads: 8, maximumSseClients: 20,
webConsoleEnabled: false, rawItemEditingEnabled: false, metricsEnabled: false,
moderationEnabled: false, claimsEnabled: false, worldTrackingEnabled: false,
rollbackEnabled: false, joinExperienceEnabled: false,
joinWelcomeEnabled: true, joinWelcomeMessage: 'Welcome {player}',
firstJoinMessageEnabled: true, firstJoinMessage: 'First join {player}',
dailyAnnouncementsEnabled: true, dailyAnnouncements: ['Daily {date}'],
announcementTimeZone: 'UTC', rulesReminderEnabled: true, rulesMessage: 'Rules',
maintenanceJoinReminderEnabled: true, defaultMessageSender: 'XFEServerManager', features: flags,
crashProtection: { enabled: true, scanIntervalTicks: 100, itemEntitiesPerDimension: 2000,
itemEntitiesPerChunk: 256, livingEntitiesPerDimension: 2000, livingEntitiesPerChunk: 128,
totalEntitiesPerDimension: 5000, totalEntitiesPerChunk: 512,
entitiesPerNamespacePerDimension: 512, loadedChunksPerDimension: 8000,
spawnBurstLimit: 400, spawnBurstWindowTicks: 100, commandBlockCommandsPerSecond: 100,
slowTickMillis: 200, consecutiveSlowTicks: 3, heapUsagePercent: 90,
blockExcessSpawns: true, removeExcessItems: true, stopRunawayCommandBlocks: true },
};
function prepare(client: ApiClient, session: SessionInfo): void {
vi.spyOn(client, 'session').mockResolvedValue(session);
vi.spyOn(client, 'status').mockResolvedValue({ sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(client, 'features').mockResolvedValue(flags);
vi.spyOn(client, 'settings').mockResolvedValue(settingsValue);
vi.spyOn(client, 'subscribe').mockReturnValue(() => undefined);
}
describe('settings account management', () => {
it('lets an owner change a feature switch from the visible feature panel', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-1', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: true,
});
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const claims = await screen.findByRole('switch', { name: /区块认领/ });
await userEvent.click(claims);
await userEvent.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({ claimsEnabled: true })));
});
it('loads, validates, and saves the daily trigger time zone without exposing retired join settings', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-time-zone', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const timeZone = await screen.findByLabelText('每日触发器时区 / Daily trigger time zone');
expect(timeZone).toHaveValue('UTC');
expect(screen.queryByLabelText(/join experience/i)).not.toBeInTheDocument();
await user.clear(timeZone);
await user.type(timeZone, 'Not/AZone');
expect(screen.getByRole('alert')).toHaveTextContent('IANA');
expect(screen.getByRole('button', { name: /保存功能与触发器设置/ })).toBeDisabled();
await user.clear(timeZone);
await user.type(timeZone, 'Asia/Shanghai');
await user.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
announcementTimeZone: 'Asia/Shanghai',
})));
});
it('validates, trims, and saves the server-wide default message sender', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-sender', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const sender = await screen.findByLabelText('默认消息发送者 / Default message sender');
expect(sender).toHaveValue('XFEServerManager');
fireEvent.change(sender, { target: { value: 'Bad\u0001Sender' } });
expect(screen.getByText(/默认消息发送者需为 1–64 个字符/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /保存功能与触发器设置/ })).toBeDisabled();
fireEvent.change(sender, { target: { value: ' Community ' } });
await user.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
defaultMessageSender: 'Community',
})));
});
it('validates and hot-applies crash protection limits', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-protection', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const chunkLimit = await screen.findByLabelText('每区块掉落物硬上限');
fireEvent.change(chunkLimit, { target: { value: '6000' } });
expect(screen.getByText(/防护数值无效/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /保存并立即应用/ })).toBeDisabled();
fireEvent.change(chunkLimit, { target: { value: '128' } });
await user.click(screen.getByRole('button', { name: /保存并立即应用/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
crashProtection: expect.objectContaining({ itemEntitiesPerChunk: 128 }),
})));
});
it('does not request or expose account controls before recent reauthentication', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(true));
const accounts = vi.spyOn(client, 'accounts');
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
expect(await screen.findByText(/Account management requires an owner/)).toBeInTheDocument();
expect(accounts).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: 'Create account' })).not.toBeInTheDocument();
});
it('creates an account and clears the submitted password', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([helper]);
const create = vi.spyOn(client, 'createAccount').mockResolvedValue({ ...helper, id: 'new-id', username: 'new-helper' });
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const username = await screen.findByLabelText('Username');
const password = screen.getByLabelText(/^Initial password/);
await user.type(username, 'new-helper');
await user.type(password, 'very-strong-password');
await user.selectOptions(screen.getByLabelText('Role'), 'moderator');
await user.click(screen.getByRole('button', { name: 'Create account' }));
await waitFor(() => expect(create).toHaveBeenCalledWith('new-helper', 'very-strong-password', 'moderator'));
await waitFor(() => expect(password).toHaveValue(''));
expect(screen.queryByDisplayValue('very-strong-password')).not.toBeInTheDocument();
});
it('requires a second click before applying role changes or deleting', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([helper]);
const update = vi.spyOn(client, 'updateAccount').mockResolvedValue({ ...helper, role: 'moderator' });
const remove = vi.spyOn(client, 'deleteAccount').mockResolvedValue(undefined);
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
await user.selectOptions(await screen.findByLabelText('Role: helper'), 'moderator');
await user.click(screen.getByRole('button', { name: 'Review account change' }));
expect(update).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Confirm account change' }));
await waitFor(() => expect(update).toHaveBeenCalledWith('helper-id', { role: 'moderator', disabled: false }));
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(remove).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => expect(remove).toHaveBeenCalledWith('helper-id'));
});
});
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { App } from '../app';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import type { AccountView, ServerSettings, SessionInfo } from '../types';
const flags = { consoleEnabled: false, moderationEnabled: false, claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false };
const ownerSession = (reauthenticationRequired: boolean): SessionInfo => ({
authenticated: true,
csrfToken: 'csrf',
actor: { id: 'owner-id', displayName: 'owner', roles: ['owner'], permissions: [], totpVerified: true, totpEnabled: true, reauthenticationRequired },
});
const helper: AccountView = {
id: 'helper-id', username: 'helper', role: 'helper', totpEnabled: false, disabled: false,
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null,
};
const settingsValue: ServerSettings = {
bindAddress: '127.0.0.1', port: 8765, publicBaseUrl: 'http://127.0.0.1:8765',
trustedProxyCidrs: [], idleSessionSeconds: 1800, absoluteSessionSeconds: 28800,
recentAuthenticationSeconds: 300, httpThreads: 8, maximumSseClients: 20,
webConsoleEnabled: false, rawItemEditingEnabled: false, metricsEnabled: false,
moderationEnabled: false, claimsEnabled: false, worldTrackingEnabled: false,
rollbackEnabled: false, joinExperienceEnabled: false,
joinWelcomeEnabled: true, joinWelcomeMessage: 'Welcome {player}',
firstJoinMessageEnabled: true, firstJoinMessage: 'First join {player}',
dailyAnnouncementsEnabled: true, dailyAnnouncements: ['Daily {date}'],
announcementTimeZone: 'UTC', rulesReminderEnabled: true, rulesMessage: 'Rules',
maintenanceJoinReminderEnabled: true, defaultMessageSender: 'XFEServerManager', features: flags,
crashProtection: { enabled: true, scanIntervalTicks: 100, itemEntitiesPerDimension: 2000,
itemEntitiesPerChunk: 256, livingEntitiesPerDimension: 2000, livingEntitiesPerChunk: 128,
totalEntitiesPerDimension: 5000, totalEntitiesPerChunk: 512,
entitiesPerNamespacePerDimension: 512, loadedChunksPerDimension: 8000,
spawnBurstLimit: 400, spawnBurstWindowTicks: 100, commandBlockCommandsPerSecond: 100,
slowTickMillis: 200, consecutiveSlowTicks: 3, heapUsagePercent: 90,
blockExcessSpawns: true, removeExcessItems: true, stopRunawayCommandBlocks: true },
};
function prepare(client: ApiClient, session: SessionInfo): void {
vi.spyOn(client, 'session').mockResolvedValue(session);
vi.spyOn(client, 'status').mockResolvedValue({ sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(client, 'features').mockResolvedValue(flags);
vi.spyOn(client, 'settings').mockResolvedValue(settingsValue);
vi.spyOn(client, 'subscribe').mockReturnValue(() => undefined);
}
describe('settings account management', () => {
it('lets an owner change a feature switch from the visible feature panel', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-1', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: true,
});
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const claims = await screen.findByRole('switch', { name: /区块认领/ });
await userEvent.click(claims);
await userEvent.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({ claimsEnabled: true })));
});
it('loads, validates, and saves the daily trigger time zone without exposing retired join settings', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-time-zone', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const timeZone = await screen.findByLabelText('每日触发器时区 / Daily trigger time zone');
expect(timeZone).toHaveValue('UTC');
expect(screen.queryByLabelText(/join experience/i)).not.toBeInTheDocument();
await user.clear(timeZone);
await user.type(timeZone, 'Not/AZone');
expect(screen.getByRole('alert')).toHaveTextContent('IANA');
expect(screen.getByRole('button', { name: /保存功能与触发器设置/ })).toBeDisabled();
await user.clear(timeZone);
await user.type(timeZone, 'Asia/Shanghai');
await user.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
announcementTimeZone: 'Asia/Shanghai',
})));
});
it('validates, trims, and saves the server-wide default message sender', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-sender', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const sender = await screen.findByLabelText('默认消息发送者 / Default message sender');
expect(sender).toHaveValue('XFEServerManager');
fireEvent.change(sender, { target: { value: 'Bad\u0001Sender' } });
expect(screen.getByText(/默认消息发送者需为 1–64 个字符/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /保存功能与触发器设置/ })).toBeDisabled();
fireEvent.change(sender, { target: { value: ' Community ' } });
await user.click(screen.getByRole('button', { name: /保存功能与触发器设置/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
defaultMessageSender: 'Community',
})));
});
it('validates and hot-applies crash protection limits', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([]);
const update = vi.spyOn(client, 'updateSettings').mockResolvedValue({
id: 'settings-protection', kind: 'settings.update', state: 'SUCCEEDED', restartRequired: false,
});
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const chunkLimit = await screen.findByLabelText('每区块掉落物硬上限');
fireEvent.change(chunkLimit, { target: { value: '6000' } });
expect(screen.getByText(/防护数值无效/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /保存并立即应用/ })).toBeDisabled();
fireEvent.change(chunkLimit, { target: { value: '128' } });
await user.click(screen.getByRole('button', { name: /保存并立即应用/ }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
crashProtection: expect.objectContaining({ itemEntitiesPerChunk: 128 }),
})));
});
it('does not request or expose account controls before recent reauthentication', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(true));
const accounts = vi.spyOn(client, 'accounts');
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
expect(await screen.findByText(/Account management requires an owner/)).toBeInTheDocument();
expect(accounts).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: 'Create account' })).not.toBeInTheDocument();
});
it('creates an account and clears the submitted password', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([helper]);
const create = vi.spyOn(client, 'createAccount').mockResolvedValue({ ...helper, id: 'new-id', username: 'new-helper' });
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
const username = await screen.findByLabelText('Username');
const password = screen.getByLabelText(/^Initial password/);
await user.type(username, 'new-helper');
await user.type(password, 'very-strong-password');
await user.selectOptions(screen.getByLabelText('Role'), 'moderator');
await user.click(screen.getByRole('button', { name: 'Create account' }));
await waitFor(() => expect(create).toHaveBeenCalledWith('new-helper', 'very-strong-password', 'moderator'));
await waitFor(() => expect(password).toHaveValue(''));
expect(screen.queryByDisplayValue('very-strong-password')).not.toBeInTheDocument();
});
it('requires a second click before applying role changes or deleting', async () => {
localStorage.setItem('xfesm.locale', 'en-US');
location.hash = '/settings';
const client = new ApiClient();
prepare(client, ownerSession(false));
vi.spyOn(client, 'accounts').mockResolvedValue([helper]);
const update = vi.spyOn(client, 'updateAccount').mockResolvedValue({ ...helper, role: 'moderator' });
const remove = vi.spyOn(client, 'deleteAccount').mockResolvedValue(undefined);
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client}><App /></ServerProvider></I18nProvider>);
await user.selectOptions(await screen.findByLabelText('Role: helper'), 'moderator');
await user.click(screen.getByRole('button', { name: 'Review account change' }));
expect(update).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Confirm account change' }));
await waitFor(() => expect(update).toHaveBeenCalledWith('helper-id', { role: 'moderator', disabled: false }));
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(remove).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => expect(remove).toHaveBeenCalledWith('helper-id'));
});
});