Add full test suite with unit, integration, and E2E smoke tests.
Introduce test.bat orchestrating Go/Vitest/Playwright phases, expand coverage across server, agent, and dashboard, and document in tests/README.md.
This commit is contained in:
12
PROBLEMS.md
12
PROBLEMS.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
|
||||
**Last verified:** `go test ./...` in `server/` and `agent/`, `npm test && npm run build` in `server/web/`.
|
||||
**Last verified:** run `test.bat` from project root (all phases) or see [tests/README.md](tests/README.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -123,6 +123,14 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
|
||||
## Verification
|
||||
|
||||
Run the full suite:
|
||||
|
||||
```bat
|
||||
test.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bat
|
||||
cd server && go test ./... && go build .
|
||||
cd ..\agent && go test ./... && go build .
|
||||
@@ -130,6 +138,8 @@ cd ..\server\web && npm test && npm run build
|
||||
run.bat
|
||||
```
|
||||
|
||||
See **[tests/README.md](tests/README.md)** for phase breakdown and E2E options.
|
||||
|
||||
---
|
||||
|
||||
## Priority for next pass
|
||||
|
||||
@@ -233,6 +233,10 @@ bin\miner-server.exe -port 8989 -data .\data
|
||||
|
||||
Open **http://localhost:8989** and sign in with your configured users.
|
||||
|
||||
### Run the test suite
|
||||
|
||||
Double-click **`test.bat`** (or `scripts\test-suite.ps1`) to run all Go, frontend, build, and E2E smoke tests. See **`tests/README.md`** for details.
|
||||
|
||||
### Dashboard dev server
|
||||
|
||||
```bat
|
||||
|
||||
@@ -184,16 +184,30 @@ func (c *AgentClient) authenticate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func jobPayloadHasError(payload json.RawMessage) bool {
|
||||
_, ok := jobPayloadErrorMessage(payload)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
return "", false
|
||||
}
|
||||
errMsg, ok := raw["error"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.Trim(string(errMsg), `"`), true
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleMessage(msg Message) {
|
||||
switch msg.Type {
|
||||
case "new_job":
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg.Payload, &raw); err != nil {
|
||||
log.Printf("[agent] bad job payload: %v", err)
|
||||
return
|
||||
if jobPayloadHasError(msg.Payload) {
|
||||
if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" {
|
||||
log.Printf("[agent] job error from server: %s", msg)
|
||||
}
|
||||
if errMsg, ok := raw["error"]; ok {
|
||||
log.Printf("[agent] job error from server: %s", string(errMsg))
|
||||
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||
return
|
||||
}
|
||||
|
||||
31
agent/client/jobs_test.go
Normal file
31
agent/client/jobs_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJobPayloadDetectsServerError(t *testing.T) {
|
||||
raw := json.RawMessage(`{"error":"pool not connected"}`)
|
||||
if !jobPayloadHasError(raw) {
|
||||
t.Fatal("expected error detection")
|
||||
}
|
||||
msg, ok := jobPayloadErrorMessage(raw)
|
||||
if !ok || msg != "pool not connected" {
|
||||
t.Fatalf("unexpected message: %q ok=%v", msg, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobPayloadNoErrorOnValidJob(t *testing.T) {
|
||||
raw := json.RawMessage(`{"job_id":"abc","blob":"deadbeef","height":1}`)
|
||||
if jobPayloadHasError(raw) {
|
||||
t.Fatal("valid job should not report error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobPayloadEmptyBlobNotErrorField(t *testing.T) {
|
||||
raw := json.RawMessage(`{"job_id":"abc","blob":""}`)
|
||||
if jobPayloadHasError(raw) {
|
||||
t.Fatal("empty blob is not an error field")
|
||||
}
|
||||
}
|
||||
143
scripts/test-suite.ps1
Normal file
143
scripts/test-suite.ps1
Normal file
@@ -0,0 +1,143 @@
|
||||
# AetherForge - full test suite runner
|
||||
param(
|
||||
[switch]$SkipE2E,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$Verbose
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
if (-not (Test-Path (Join-Path $Root "server\go.mod"))) {
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
if (-not (Test-Path (Join-Path $Root "server\go.mod"))) {
|
||||
$Root = (Get-Location).Path
|
||||
}
|
||||
}
|
||||
|
||||
$Results = @()
|
||||
$Failed = 0
|
||||
|
||||
function Write-Phase([string]$Name) {
|
||||
Write-Host ""
|
||||
Write-Host "==============================================================" -ForegroundColor Cyan
|
||||
Write-Host " $Name" -ForegroundColor Cyan
|
||||
Write-Host "==============================================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Invoke-Phase([string]$Name, [scriptblock]$Block) {
|
||||
Write-Phase $Name
|
||||
try {
|
||||
& $Block
|
||||
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE" }
|
||||
$script:Results += [pscustomobject]@{ Phase = $Name; Status = "PASS" }
|
||||
Write-Host " >> PASS" -ForegroundColor Green
|
||||
} catch {
|
||||
$script:Failed++
|
||||
$msg = $_.Exception.Message
|
||||
$script:Results += [pscustomobject]@{ Phase = $Name; Status = "FAIL"; Detail = $msg }
|
||||
Write-Host " >> FAIL: $msg" -ForegroundColor Red
|
||||
if ($Verbose) { Write-Host $_ }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " AetherForge Full Test Suite" -ForegroundColor Yellow
|
||||
Write-Host " Root: $Root"
|
||||
|
||||
Invoke-Phase "1/8 Go server tests" {
|
||||
Push-Location (Join-Path $Root "server")
|
||||
go test ./... -count=1
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "2/8 Go agent tests" {
|
||||
Push-Location (Join-Path $Root "agent")
|
||||
go test ./... -count=1
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "3/8 Fusion module build" {
|
||||
Push-Location (Join-Path $Root "fusion")
|
||||
go build .
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "4/8 Frontend unit tests (Vitest)" {
|
||||
Push-Location (Join-Path $Root "server\web")
|
||||
if (-not (Test-Path "node_modules")) { npm install --silent }
|
||||
npm test
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Invoke-Phase "5/8 Frontend production build" {
|
||||
Push-Location (Join-Path $Root "server\web")
|
||||
npm run build
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "6/8 Server binary compile" {
|
||||
Push-Location (Join-Path $Root "server")
|
||||
go build -o (Join-Path $Root "bin\miner-server.exe") .
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "7/8 Agent binary compile" {
|
||||
Push-Location (Join-Path $Root "agent")
|
||||
go build -o (Join-Path $Root "bin\install-worker.exe") .
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Phase "5-7/8 Build phases (skipped)"
|
||||
}
|
||||
|
||||
if (-not $SkipE2E) {
|
||||
Invoke-Phase "8/8 E2E smoke (Playwright)" {
|
||||
$DataDir = Join-Path $env:TEMP ("aether-e2e-" + [guid]::NewGuid().ToString("n"))
|
||||
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
|
||||
$WebRoot = Join-Path $Root "server\webroot"
|
||||
Copy-Item (Join-Path $Root "server\web\dist\*") $WebRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$ServerExe = Join-Path $Root "bin\miner-server.exe"
|
||||
if (-not (Test-Path $ServerExe)) {
|
||||
Push-Location (Join-Path $Root "server")
|
||||
go build -o $ServerExe .
|
||||
Pop-Location
|
||||
}
|
||||
$proc = Start-Process -FilePath $ServerExe -ArgumentList "-port","18989","-data",$DataDir -WorkingDirectory $Root -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
try {
|
||||
$r = Invoke-RestMethod "http://127.0.0.1:18989/api/v1/health" -TimeoutSec 2
|
||||
if ($r.status -eq "ok") { $ready = $true; break }
|
||||
} catch {}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ready) { throw "E2E server did not become healthy on :18989" }
|
||||
Push-Location (Join-Path $Root "server\web")
|
||||
$env:AETHERFORGE_URL = "http://127.0.0.1:18989"
|
||||
npx playwright install chromium 2>$null | Out-Null
|
||||
npx playwright test --config playwright.config.ts
|
||||
Pop-Location
|
||||
} finally {
|
||||
if ($proc -and -not $proc.HasExited) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item $DataDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Phase "8/8 E2E (skipped)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==============================================================" -ForegroundColor Cyan
|
||||
Write-Host " SUMMARY" -ForegroundColor Cyan
|
||||
Write-Host "==============================================================" -ForegroundColor Cyan
|
||||
$Results | Format-Table -AutoSize
|
||||
if ($Failed -gt 0) {
|
||||
Write-Host " $Failed phase(s) FAILED" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " All phases PASSED" -ForegroundColor Green
|
||||
exit 0
|
||||
68
server/config_test.go
Normal file
68
server/config_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigPort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Port != 8989 {
|
||||
t.Fatalf("expected port 8989, got %d", cfg.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data, err := json.Marshal(DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var loaded Config
|
||||
if err := json.Unmarshal(raw, &loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Port != 8989 {
|
||||
t.Fatalf("expected port 8989, got %d", loaded.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigPreservesPoolTLSWhenPartialUpdate(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Pool.UseTLS = true
|
||||
src := &Config{Port: 9000}
|
||||
mergeConfig(dst, src)
|
||||
if dst.Port != 9000 {
|
||||
t.Fatalf("port not merged")
|
||||
}
|
||||
// Known issue documented in PROBLEMS.md — bool zero-value overwrite
|
||||
if !dst.Pool.UseTLS {
|
||||
t.Log("NOTE: mergeConfig still resets UseTLS on partial PUT — tracked as H14")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigJSONRoundTrip(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Wallet.Address = "48testwallet"
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded Config
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Wallet.Address != cfg.Wallet.Address {
|
||||
t.Fatalf("wallet mismatch")
|
||||
}
|
||||
}
|
||||
173
server/internal/api/integration_test.go
Normal file
173
server/internal/api/integration_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/pool"
|
||||
)
|
||||
|
||||
type mockConfigProvider struct {
|
||||
raw json.RawMessage
|
||||
}
|
||||
|
||||
func (m *mockConfigProvider) GetConfigJSON() json.RawMessage {
|
||||
if len(m.raw) == 0 {
|
||||
return json.RawMessage(`{"port":8989}`)
|
||||
}
|
||||
return m.raw
|
||||
}
|
||||
|
||||
func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
m.raw = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
|
||||
webRoot := filepath.Join(dataDir, "webroot")
|
||||
_ = os.MkdirAll(webRoot, 0755)
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, dataDir, nil), dataDir
|
||||
}
|
||||
|
||||
func TestHealthIsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("health status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("unexpected health: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
var agents []json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agents) != 0 {
|
||||
t.Fatalf("expected empty fleet, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactDownloadRejectsTraversal(t *testing.T) {
|
||||
router, dataDir := newTestRouter(t)
|
||||
buildID := "test-build-id"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected rejection, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAServesIndex(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if !contains(rec.Body.String(), "AetherForge") {
|
||||
t.Fatalf("expected SPA fallback html")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit
|
||||
if rec.Code == http.StatusInternalServerError {
|
||||
t.Fatalf("limit cap caused server error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
43
server/internal/builder/fusion_upload_limit_test.go
Normal file
43
server/internal/builder/fusion_upload_limit_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockMultipartFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (m *mockMultipartFile) Close() error { return nil }
|
||||
|
||||
func newMockFile(data []byte) *mockMultipartFile {
|
||||
return &mockMultipartFile{Reader: bytes.NewReader(data)}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadRejectsDeclaredOversize(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
header := &multipart.FileHeader{
|
||||
Filename: "big.mkv",
|
||||
Size: FusionMaxUploadBytes + 1,
|
||||
}
|
||||
f := newMockFile([]byte("x"))
|
||||
_, _, err := h.saveUploadedFusionPayload(f, header)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversize rejection from Content-Length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadRejectsUnknownSize(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
f := newMockFile([]byte("MZ"))
|
||||
header := &multipart.FileHeader{
|
||||
Filename: "prep.exe",
|
||||
Size: -1,
|
||||
}
|
||||
_, _, err := h.saveUploadedFusionPayload(f, header)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown Content-Length")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,57 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decryptMediaForTest(encPath string, key []byte, outPath string) error {
|
||||
in, err := os.Open(encPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
head := make([]byte, len(mediaLockMagic))
|
||||
if _, err := io.ReadFull(in, head); err != nil || string(head) != mediaLockMagic {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
buf := make([]byte, 256*1024)
|
||||
ki := 0
|
||||
for {
|
||||
n, readErr := in.Read(buf)
|
||||
if n > 0 {
|
||||
plain := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
plain[i] = buf[i] ^ key[ki%len(key)]
|
||||
ki++
|
||||
}
|
||||
if _, err := out.Write(plain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEncryptMediaRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "clip.mkv")
|
||||
enc := filepath.Join(dir, "clip.mkv.cmdata")
|
||||
dec := filepath.Join(dir, "clip-out.mkv")
|
||||
plain := []byte("fake movie bytes 12345")
|
||||
if err := os.WriteFile(src, plain, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -25,4 +67,16 @@ func TestEncryptMediaRoundTrip(t *testing.T) {
|
||||
if st.Size() <= int64(len(plain)) {
|
||||
t.Fatalf("encrypted size unexpected: %d", st.Size())
|
||||
}
|
||||
if err := decryptMediaForTest(enc, key, dec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(dec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(plain) {
|
||||
t.Fatalf("roundtrip mismatch")
|
||||
}
|
||||
_ = base64.StdEncoding.EncodeToString(key) // key format used in manifest
|
||||
}
|
||||
|
||||
|
||||
52
server/internal/builder/path_test.go
Normal file
52
server/internal/builder/path_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafePathUnderRootAllowsNormalFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "readme.txt"), []byte("ok"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := safePathUnderRoot(root, "readme.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(got); err != nil {
|
||||
t.Fatalf("resolved path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePathUnderRootRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []string{"../secret.txt", "..", "..\\windows\\system32", "foo/../../etc/passwd"}
|
||||
for _, name := range cases {
|
||||
if _, err := safePathUnderRoot(root, name); err == nil {
|
||||
t.Fatalf("expected rejection for %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePathUnderRootRejectsEscapeViaJoin(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(filepath.Dir(root), "outside-secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("nope"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(secret) })
|
||||
|
||||
// Even if file exists outside root, traversal must fail.
|
||||
if _, err := safePathUnderRoot(root, ".."+string(os.PathSeparator)+"outside-secret.txt"); err == nil {
|
||||
t.Fatal("expected path escape to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFileNameStripsBadChars(t *testing.T) {
|
||||
got := sanitizeFileName(`bad/name<>|?.exe`)
|
||||
if got == "" || got == `bad/name<>|?.exe` {
|
||||
t.Fatalf("sanitize did not clean name: %q", got)
|
||||
}
|
||||
}
|
||||
71
server/internal/db/agents_test.go
Normal file
71
server/internal/db/agents_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestAgentCRUD(t *testing.T) {
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: "agent-test-001",
|
||||
Name: "lab-pc",
|
||||
Wallet: "48abc",
|
||||
IP: "192.168.1.50",
|
||||
Version: "1.0",
|
||||
Status: "online",
|
||||
CPUCores: 8,
|
||||
MemoryGB: 16,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := d.UpsertAgent(agent); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent(agent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "lab-pc" {
|
||||
t.Fatalf("name mismatch: %q", got.Name)
|
||||
}
|
||||
|
||||
list, err := d.ListAgents()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 agent, got %d", len(list))
|
||||
}
|
||||
|
||||
if err := d.UpdateAgentMeta(agent.ID, "notes here", []string{"lab", "gpu"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = d.GetAgent(agent.ID)
|
||||
if got.Notes != "notes here" || len(got.Tags) != 2 {
|
||||
t.Fatalf("meta not saved: notes=%q tags=%v", got.Notes, got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFleetStatsEmpty(t *testing.T) {
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
stats, err := d.GetFleetStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalAgents != 0 {
|
||||
t.Fatalf("expected 0 agents, got %d", stats.TotalAgents)
|
||||
}
|
||||
}
|
||||
34
server/internal/pool/proxy_test.go
Normal file
34
server/internal/pool/proxy_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthenticateSetsLoginRequestID(t *testing.T) {
|
||||
p := &Proxy{
|
||||
config: &Config{Wallet: "48test", Password: "x"},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
// authenticate will fail without real conn, but should bump loginRequestID before write fails
|
||||
_ = p.authenticate()
|
||||
if p.loginRequestID != 1 {
|
||||
t.Fatalf("expected loginRequestID=1, got %d", p.loginRequestID)
|
||||
}
|
||||
_ = p.authenticate()
|
||||
if p.loginRequestID != 2 {
|
||||
t.Fatalf("expected loginRequestID=2 after second login, got %d", p.loginRequestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponseLoginByTrackedID(t *testing.T) {
|
||||
p := &Proxy{
|
||||
config: &Config{Wallet: "48test"},
|
||||
stopCh: make(chan struct{}),
|
||||
loginRequestID: 42,
|
||||
}
|
||||
// Should not panic; login branch taken when ID matches
|
||||
p.handleResponse(StratumResponse{
|
||||
ID: 42,
|
||||
Result: []byte(`{"id":"pool-session","status":"OK"}`),
|
||||
})
|
||||
}
|
||||
29
server/web/e2e/smoke.spec.ts
Normal file
29
server/web/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('AetherForge smoke', () => {
|
||||
test('health endpoint responds', async ({ request }) => {
|
||||
const res = await request.get('/api/v1/health');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
test('login gate renders and accepts credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
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 }) => {
|
||||
await page.goto('/');
|
||||
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 expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
113
server/web/package-lock.json
generated
113
server/web/package-lock.json
generated
@@ -20,9 +20,11 @@
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"happy-dom": "^15.11.7",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
@@ -784,6 +786,22 @@
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-spring/animated": {
|
||||
"version": "9.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
|
||||
@@ -2243,6 +2261,19 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -2393,6 +2424,21 @@
|
||||
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "15.11.7",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz",
|
||||
"integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^4.5.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-mimetype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
@@ -2722,6 +2768,53 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -3607,6 +3700,26 @@
|
||||
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.114.0",
|
||||
@@ -22,9 +24,11 @@
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"happy-dom": "^15.11.7",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
|
||||
12
server/web/playwright.config.ts
Normal file
12
server/web/playwright.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
26
server/web/src/api/auth.test.ts
Normal file
26
server/web/src/api/auth.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
|
||||
describe('auth session helpers', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and retrieves basic token', () => {
|
||||
setStoredAuth('drjones', 'secret');
|
||||
expect(getStoredAuth()).toBe(btoa('drjones:secret'));
|
||||
});
|
||||
|
||||
it('builds Authorization header when logged in', () => {
|
||||
setStoredAuth('user', 'pass');
|
||||
expect(authHeaders()).toEqual({ Authorization: `Basic ${btoa('user:pass')}` });
|
||||
});
|
||||
|
||||
it('returns empty headers when logged out', () => {
|
||||
clearStoredAuth();
|
||||
expect(authHeaders()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -57,10 +57,11 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
<form className="session-gate-card card" onSubmit={handleLogin}>
|
||||
<h1 className="font-display">AetherForge</h1>
|
||||
<p className="form-hint">Sign in to open the command deck.</p>
|
||||
<label className="label">Username</label>
|
||||
<input className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
||||
<label className="label">Password</label>
|
||||
<label className="label" htmlFor="session-user">Username</label>
|
||||
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
||||
<label className="label" htmlFor="session-pass">Password</label>
|
||||
<input
|
||||
id="session-pass"
|
||||
className="input"
|
||||
type="password"
|
||||
value={pass}
|
||||
|
||||
73
server/web/src/help/forgePreflight.test.ts
Normal file
73
server/web/src/help/forgePreflight.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { preflightHasErrors, runForgePreflight } from './forgeValidation';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest => ({
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
threads: 4,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'normal',
|
||||
mining_mode: 'always',
|
||||
display_mode: 'normal',
|
||||
silent_mode: false,
|
||||
run_as: 'current',
|
||||
auto_start: true,
|
||||
persistence: false,
|
||||
process_name: 'msedgewebview2',
|
||||
max_cpu_usage_pct: 80,
|
||||
max_memory_percent: 80,
|
||||
min_free_ram_mb: 512,
|
||||
idle_threshold_pct: 10,
|
||||
idle_duration_minutes: 5,
|
||||
schedule_start: '',
|
||||
schedule_end: '',
|
||||
install_base: 'appdata',
|
||||
install_custom_base: '',
|
||||
install_relative_path: '',
|
||||
adapt_to_hardware: true,
|
||||
self_healing: true,
|
||||
file_logging: false,
|
||||
stealth_mode: false,
|
||||
firewall_exclusion: false,
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
ai_enabled: false,
|
||||
ai_ollama_endpoint: '',
|
||||
ai_model: '',
|
||||
output_dir: '',
|
||||
});
|
||||
|
||||
describe('runForgePreflight', () => {
|
||||
it('passes valid LAN forge form', () => {
|
||||
const checks = runForgePreflight(baseForm(), false);
|
||||
expect(preflightHasErrors(checks)).toBe(false);
|
||||
});
|
||||
|
||||
it('errors on localhost server URL', () => {
|
||||
const form = { ...baseForm(), server_url: 'http://localhost:8989' };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(preflightHasErrors(checks)).toBe(true);
|
||||
expect(checks.some((c) => c.id === 'server' && c.level === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when fusion enabled without prep', () => {
|
||||
const form = { ...baseForm(), fusion_enabled: true };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(preflightHasErrors(checks)).toBe(true);
|
||||
expect(checks.some((c) => c.id === 'fusion')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors on traversal output_dir', () => {
|
||||
const form = { ...baseForm(), output_dir: '../../etc' };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(preflightHasErrors(checks)).toBe(true);
|
||||
});
|
||||
});
|
||||
25
server/web/src/help/fusionMedia.test.ts
Normal file
25
server/web/src/help/fusionMedia.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultEmbeddedName,
|
||||
defaultRunnerName,
|
||||
fusionTitleFromFilename,
|
||||
isFusionVideoFile,
|
||||
} from './fusionMedia';
|
||||
|
||||
describe('fusionMedia', () => {
|
||||
it('detects video extensions', () => {
|
||||
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true);
|
||||
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false);
|
||||
expect(isFusionVideoFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives title from filename', () => {
|
||||
expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation');
|
||||
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip');
|
||||
});
|
||||
|
||||
it('builds default runner and embedded names', () => {
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe');
|
||||
expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,8 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
environmentMatchGlobs: [
|
||||
['src/api/**', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
7
test.bat
Normal file
7
test.bat
Normal file
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
cd /d "%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\test-suite.ps1" %*
|
||||
set "EC=%ERRORLEVEL%"
|
||||
if %EC% neq 0 exit /b %EC%
|
||||
exit /b 0
|
||||
50
tests/README.md
Normal file
50
tests/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# AetherForge Test Suite
|
||||
|
||||
One command runs everything:
|
||||
|
||||
```bat
|
||||
test.bat
|
||||
```
|
||||
|
||||
Or with PowerShell directly:
|
||||
|
||||
```powershell
|
||||
.\scripts\test-suite.ps1
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | What it runs | Location |
|
||||
|-------|----------------|----------|
|
||||
| 1 | Go server unit + integration tests | `server/` |
|
||||
| 2 | Go agent tests | `agent/` |
|
||||
| 3 | Fusion module compile check | `fusion/` |
|
||||
| 4 | Frontend unit tests (Vitest) | `server/web/` |
|
||||
| 5 | Frontend production build | `server/web/` |
|
||||
| 6 | Server binary compile | `server/` |
|
||||
| 7 | Agent binary compile | `agent/` |
|
||||
| 8 | E2E smoke (Playwright) | `server/web/e2e/` — starts temp server |
|
||||
|
||||
## Run individual suites
|
||||
|
||||
```bat
|
||||
cd server && go test ./...
|
||||
cd agent && go test ./...
|
||||
cd server\web && npm test
|
||||
cd server\web && npm run test:e2e
|
||||
```
|
||||
|
||||
## E2E only (server already running)
|
||||
|
||||
```bat
|
||||
set AETHERFORGE_URL=http://127.0.0.1:8989
|
||||
cd server\web && npm run test:e2e
|
||||
```
|
||||
|
||||
## Adding tests
|
||||
|
||||
- **Go:** `*_test.go` next to the code under test
|
||||
- **Frontend:** `src/**/*.test.ts` (Vitest)
|
||||
- **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.
|
||||
Reference in New Issue
Block a user