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:
drjones
2026-05-29 10:04:52 -07:00
parent f9e26bb1a6
commit e55f11a657
23 changed files with 1052 additions and 12 deletions

View 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())
}
}

View 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")
}
}

View File

@@ -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
}

View 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)
}
}

View 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)
}
}

View 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"}`),
})
}