feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
76
server/internal/db/agent_meta_test.go
Normal file
76
server/internal/db/agent_meta_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeTagsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"brackets", "[]", 0},
|
||||
{"whitespace", " [] ", 0},
|
||||
{"valid", `["a","b"]`, 2},
|
||||
{"invalid json", "{not-json", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := decodeTags(tc.raw)
|
||||
if len(got) != tc.want {
|
||||
t.Fatalf("decodeTags(%q) len=%d want %d (%v)", tc.raw, len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTagsEmpty(t *testing.T) {
|
||||
if got := encodeTags(nil); got != "[]" {
|
||||
t.Fatalf("encodeTags(nil) = %q want []", got)
|
||||
}
|
||||
if got := encodeTags([]string{}); got != "[]" {
|
||||
t.Fatalf("encodeTags(empty) = %q want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentInvalidTagsInDB(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "bad-tags")
|
||||
|
||||
if _, err := d.Exec(`UPDATE agents SET tags = ? WHERE id = ?`, "{invalid", "bad-tags"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent("bad-tags")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Tags) != 0 {
|
||||
t.Fatalf("invalid tags should decode to empty slice, got %v", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentMetaClearsTags(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "meta-clear")
|
||||
|
||||
if err := d.UpdateAgentMeta("meta-clear", "note", []string{"x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateAgentMeta("meta-clear", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent("meta-clear")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Notes != "" {
|
||||
t.Fatalf("notes not cleared: %q", got.Notes)
|
||||
}
|
||||
if len(got.Tags) != 0 {
|
||||
t.Fatalf("tags not cleared: %v", got.Tags)
|
||||
}
|
||||
}
|
||||
191
server/internal/db/builds_test.go
Normal file
191
server/internal/db/builds_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func insertBuild(t *testing.T, d *Database, b *models.BuildRecord) {
|
||||
t.Helper()
|
||||
if err := d.InsertBuild(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCRUDAndList(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
|
||||
created := time.Now().UTC().Truncate(time.Second)
|
||||
build := &models.BuildRecord{
|
||||
ID: "build-001",
|
||||
WorkerName: "rig1",
|
||||
ServerURL: "http://localhost:8080",
|
||||
Wallet: "48xyz",
|
||||
Threads: 4,
|
||||
FileSize: 1024,
|
||||
BundleSize: 2048,
|
||||
FilePath: "/data/builds/build-001.zip",
|
||||
FileName: "agent.zip",
|
||||
DownloadURL: "/api/v1/builds/build-001/download",
|
||||
Platform: "windows",
|
||||
CreatedAt: created,
|
||||
PoolHost: "pool.example.com",
|
||||
PoolPort: 443,
|
||||
PoolTLS: true,
|
||||
PoolPass: "x",
|
||||
}
|
||||
insertBuild(t, d, build)
|
||||
|
||||
got, err := d.GetBuild("build-001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.WorkerName != "rig1" || got.Platform != "windows" || got.BundleSize != 2048 {
|
||||
t.Fatalf("build mismatch: %+v", got)
|
||||
}
|
||||
if got.Pinned {
|
||||
t.Fatal("new build should not be pinned")
|
||||
}
|
||||
|
||||
list, err := d.ListBuilds(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != "build-001" {
|
||||
t.Fatalf("list builds: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBuildNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetBuild("missing")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPinnedBuild(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
now := time.Now()
|
||||
insertBuild(t, d, &models.BuildRecord{ID: "b1", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now})
|
||||
insertBuild(t, d, &models.BuildRecord{ID: "b2", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now.Add(time.Second)})
|
||||
|
||||
if err := d.SetPinnedBuild("b1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b1, _ := d.GetBuild("b1")
|
||||
b2, _ := d.GetBuild("b2")
|
||||
if !b1.Pinned || b2.Pinned {
|
||||
t.Fatalf("pin state wrong: b1=%v b2=%v", b1.Pinned, b2.Pinned)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b1, _ = d.GetBuild("b1")
|
||||
if b1.Pinned {
|
||||
t.Fatal("empty id should unpin all builds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBuildForPlatform(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "old-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "linux", CreatedAt: base,
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "linux", CreatedAt: base.Add(time.Minute),
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-windows", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "windows", CreatedAt: base.Add(2 * time.Minute),
|
||||
})
|
||||
|
||||
latestLinux, err := d.GetLatestBuildForPlatform("linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latestLinux.ID != "new-linux" {
|
||||
t.Fatalf("latest linux: got %q", latestLinux.ID)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild("old-linux"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinnedLinux, err := d.GetLatestBuildForPlatform("linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinnedLinux.ID != "old-linux" {
|
||||
t.Fatalf("pinned linux: got %q", pinnedLinux.ID)
|
||||
}
|
||||
|
||||
anyLatest, err := d.GetLatestBuildForPlatform("any")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyLatest.ID != "old-linux" {
|
||||
t.Fatalf("any with pinned: got %q want old-linux", anyLatest.ID)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPlatform, err := d.GetLatestBuildForPlatform("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if emptyPlatform.ID != "new-windows" {
|
||||
t.Fatalf("empty platform latest: got %q", emptyPlatform.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBuildForPlatformEmpty(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetLatestBuildForPlatform("linux")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows on empty db, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuildsOlderThanAndDeleteBuild(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
cutoff := time.Now().UTC().Truncate(time.Second)
|
||||
oldTime := cutoff.Add(-time.Hour)
|
||||
newTime := cutoff.Add(time.Hour)
|
||||
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "old-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: oldTime,
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: newTime,
|
||||
})
|
||||
|
||||
older, err := d.ListBuildsOlderThan(cutoff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(older) != 1 || older[0].ID != "old-build" {
|
||||
t.Fatalf("ListBuildsOlderThan: %+v", older)
|
||||
}
|
||||
|
||||
if err := d.DeleteBuild("old-build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = d.GetBuild("old-build")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected deleted build missing, got %v", err)
|
||||
}
|
||||
|
||||
if err := d.DeleteBuild("never-existed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,15 @@ func (d *Database) PurgeHashrateSamplesBefore(cutoff time.Time) (int64, error) {
|
||||
|
||||
// ListBuildsOlderThan returns build records created before cutoff.
|
||||
func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE created_at < ?`, cutoff)
|
||||
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
b, err := scanBuild(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
||||
255
server/internal/db/sqlite_test.go
Normal file
255
server/internal/db/sqlite_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *Database {
|
||||
t.Helper()
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
|
||||
t.Helper()
|
||||
a := &models.Agent{
|
||||
ID: id,
|
||||
Name: "worker-" + id,
|
||||
Wallet: "wallet",
|
||||
IP: "10.0.0.1",
|
||||
Version: "2.0",
|
||||
Status: "offline",
|
||||
CPUCores: 4,
|
||||
MemoryGB: 8,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := d.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestNewCreatesDatabase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
var n int
|
||||
if err := d.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected agents table, got count %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetAgent("missing-agent")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "persist-created")
|
||||
|
||||
first, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
a.Name = "renamed"
|
||||
a.Status = "online"
|
||||
if err := d.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.CreatedAt.Equal(first.CreatedAt) {
|
||||
t.Fatalf("created_at changed: %v -> %v", first.CreatedAt, second.CreatedAt)
|
||||
}
|
||||
if second.Name != "renamed" {
|
||||
t.Fatalf("name not updated: %q", second.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentStats(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "stats-agent")
|
||||
|
||||
if err := d.UpdateAgentStats(a.ID, 100, 200, 300, 10, 8, 2, 55.5, 66.6, 3600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "online" {
|
||||
t.Fatalf("status: got %q want online", got.Status)
|
||||
}
|
||||
if got.Hashrate15s != 100 || got.Hashrate1m != 200 || got.Hashrate15m != 300 {
|
||||
t.Fatalf("hashrate mismatch: %+v", got)
|
||||
}
|
||||
if got.SharesTotal != 10 || got.SharesGood != 8 || got.SharesBad != 2 {
|
||||
t.Fatalf("shares mismatch: %+v", got)
|
||||
}
|
||||
if got.CPUUsagePct != 55.5 || got.MemoryUsagePct != 66.6 || got.UptimeSeconds != 3600 {
|
||||
t.Fatalf("usage/uptime mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentOffline(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "offline-agent")
|
||||
if err := d.UpdateAgentStats(a.ID, 1, 1, 1, 0, 0, 0, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := d.SetAgentOffline(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "offline" {
|
||||
t.Fatalf("status: got %q want offline", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareInsertUpdateAndRecent(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "share-agent")
|
||||
|
||||
ts := time.Now().UTC().Truncate(time.Second)
|
||||
share := &models.Share{
|
||||
AgentID: "share-agent",
|
||||
JobID: "job-1",
|
||||
Difficulty: 1000,
|
||||
Accepted: false,
|
||||
Hash: "abc123",
|
||||
Nonce: "deadbeef",
|
||||
Error: "pending",
|
||||
Timestamp: ts,
|
||||
}
|
||||
id, err := d.InsertShare(share)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("expected positive insert id, got %d", id)
|
||||
}
|
||||
|
||||
if err := d.UpdateShareResult(id, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recent, err := d.GetRecentShares(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(recent))
|
||||
}
|
||||
if !recent[0].Accepted {
|
||||
t.Fatal("share should be accepted after update")
|
||||
}
|
||||
if recent[0].Error != "" {
|
||||
t.Fatalf("error should be cleared, got %q", recent[0].Error)
|
||||
}
|
||||
|
||||
if err := d.UpdateShareResult(id, false, "pool rejected"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recent, err = d.GetRecentShares(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recent[0].Accepted || recent[0].Error != "pool rejected" {
|
||||
t.Fatalf("unexpected share after reject update: %+v", recent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashrateSampleHistory(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "hr-agent")
|
||||
|
||||
if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := d.GetHashrateHistory("hr-agent", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2 samples, got %d", len(history))
|
||||
}
|
||||
if history[0].Hashrate != 200.0 {
|
||||
t.Fatalf("expected newest first, got %f", history[0].Hashrate)
|
||||
}
|
||||
|
||||
empty, err := d.GetHashrateHistory("unknown", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("expected empty history, got %d", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFleetStatsWithAgents(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "fleet-a")
|
||||
seedAgent(t, d, "fleet-b")
|
||||
|
||||
if err := d.UpdateAgentStats("fleet-a", 0, 0, 100, 20, 18, 2, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateAgentStats("fleet-b", 0, 0, 50, 10, 5, 5, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := d.GetFleetStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalAgents != 2 {
|
||||
t.Fatalf("total agents: got %d want 2", stats.TotalAgents)
|
||||
}
|
||||
if stats.OnlineAgents != 2 {
|
||||
t.Fatalf("online agents: got %d want 2", stats.OnlineAgents)
|
||||
}
|
||||
if stats.TotalHashrate != 150 {
|
||||
t.Fatalf("total hashrate: got %f want 150", stats.TotalHashrate)
|
||||
}
|
||||
if stats.TotalShares != 30 || stats.AcceptedShares != 23 || stats.RejectedShares != 7 {
|
||||
t.Fatalf("share totals mismatch: %+v", stats)
|
||||
}
|
||||
wantRate := float64(23) / float64(30) * 100
|
||||
if stats.AcceptRate != wantRate {
|
||||
t.Fatalf("accept rate: got %f want %f", stats.AcceptRate, wantRate)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user