mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 08:57:12 +00:00
1299 lines
44 KiB
Go
1299 lines
44 KiB
Go
package truenas
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
type apiResponse struct {
|
|
status int
|
|
body string
|
|
contentType string
|
|
}
|
|
|
|
type closeTrackingTransport struct {
|
|
closeCalls int
|
|
}
|
|
|
|
func (t *closeTrackingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (t *closeTrackingTransport) CloseIdleConnections() {
|
|
t.closeCalls++
|
|
}
|
|
|
|
func TestClientGetters(t *testing.T) {
|
|
server := newMockServer(t, defaultAPIResponses(), nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
ctx := context.Background()
|
|
|
|
system, err := client.GetSystemInfo(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetSystemInfo() error = %v", err)
|
|
}
|
|
if system.Hostname != "truenas-main" || system.Version != "TrueNAS-SCALE-24.10.2" {
|
|
t.Fatalf("unexpected system info: %+v", system)
|
|
}
|
|
if system.Build != "24.10.2.1" || system.UptimeSeconds != 86400 {
|
|
t.Fatalf("unexpected system fields: %+v", system)
|
|
}
|
|
if !system.Healthy || system.MachineID != "SER123" {
|
|
t.Fatalf("unexpected system health/identity: %+v", system)
|
|
}
|
|
if system.CPUCount != 16 || system.MemoryTotalBytes != 68719476736 {
|
|
t.Fatalf("unexpected system capacity mapping: %+v", system)
|
|
}
|
|
|
|
pools, err := client.GetPools(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetPools() error = %v", err)
|
|
}
|
|
if len(pools) != 1 {
|
|
t.Fatalf("expected 1 pool, got %d", len(pools))
|
|
}
|
|
if pools[0].ID != "1" || pools[0].Name != "tank" || pools[0].UsedBytes != 400 {
|
|
t.Fatalf("unexpected pool mapping: %+v", pools[0])
|
|
}
|
|
|
|
datasets, err := client.GetDatasets(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetDatasets() error = %v", err)
|
|
}
|
|
if len(datasets) != 1 {
|
|
t.Fatalf("expected 1 dataset, got %d", len(datasets))
|
|
}
|
|
if datasets[0].ID != "tank/apps" || datasets[0].UsedBytes != 12345 || datasets[0].AvailBytes != 555 {
|
|
t.Fatalf("unexpected dataset usage mapping: %+v", datasets[0])
|
|
}
|
|
if !datasets[0].Mounted || datasets[0].ReadOnly {
|
|
t.Fatalf("unexpected dataset mount/readonly mapping: %+v", datasets[0])
|
|
}
|
|
|
|
disks, err := client.GetDisks(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetDisks() error = %v", err)
|
|
}
|
|
if len(disks) != 2 {
|
|
t.Fatalf("expected 2 disks, got %d", len(disks))
|
|
}
|
|
if disks[0].Transport != "sata" || !disks[0].Rotational {
|
|
t.Fatalf("unexpected rotational disk mapping: %+v", disks[0])
|
|
}
|
|
if disks[1].Transport != "nvme" || disks[1].Rotational {
|
|
t.Fatalf("unexpected nvme disk mapping: %+v", disks[1])
|
|
}
|
|
if disks[0].Temperature != 34 || disks[1].Temperature != 49 {
|
|
t.Fatalf("unexpected disk temperatures: %+v", disks)
|
|
}
|
|
|
|
alerts, err := client.GetAlerts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetAlerts() error = %v", err)
|
|
}
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
|
}
|
|
if alerts[0].ID != "a1" || alerts[0].Level != "WARNING" {
|
|
t.Fatalf("unexpected alert identity mapping: %+v", alerts[0])
|
|
}
|
|
if alerts[0].Datetime != time.UnixMilli(1707400000000).UTC() {
|
|
t.Fatalf("unexpected alert datetime: %s", alerts[0].Datetime)
|
|
}
|
|
|
|
apps, err := client.GetApps(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetApps() error = %v", err)
|
|
}
|
|
if len(apps) != 1 {
|
|
t.Fatalf("expected 1 app, got %d", len(apps))
|
|
}
|
|
if apps[0].ID != "nextcloud" || apps[0].Name != "Nextcloud" {
|
|
t.Fatalf("unexpected app identity mapping: %+v", apps[0])
|
|
}
|
|
if apps[0].ContainerCount != 2 || len(apps[0].Containers) != 2 {
|
|
t.Fatalf("unexpected app container mapping: %+v", apps[0])
|
|
}
|
|
if len(apps[0].UsedPorts) != 1 || apps[0].UsedPorts[0].ContainerPort != 443 {
|
|
t.Fatalf("unexpected app used ports mapping: %+v", apps[0].UsedPorts)
|
|
}
|
|
if len(apps[0].Volumes) != 2 || len(apps[0].Networks) != 1 {
|
|
t.Fatalf("unexpected app volume/network mapping: volumes=%d networks=%d", len(apps[0].Volumes), len(apps[0].Networks))
|
|
}
|
|
}
|
|
|
|
func TestClientAuthHeaderAPIKey(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: `{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`},
|
|
}, func(t *testing.T, request *http.Request) {
|
|
if got := request.Header.Get("Authorization"); got != "Bearer test-key" {
|
|
t.Fatalf("expected bearer auth header, got %q", got)
|
|
}
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "test-key"})
|
|
if err := client.TestConnection(context.Background()); err != nil {
|
|
t.Fatalf("TestConnection() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientAuthHeaderBasic(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: `{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`},
|
|
}, func(t *testing.T, request *http.Request) {
|
|
username, password, ok := request.BasicAuth()
|
|
if !ok {
|
|
t.Fatalf("expected basic auth")
|
|
}
|
|
if username != "admin" || password != "secret" {
|
|
t.Fatalf("unexpected basic auth credentials: %q:%q", username, password)
|
|
}
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{Username: "admin", Password: "secret"})
|
|
if err := client.TestConnection(context.Background()); err != nil {
|
|
t.Fatalf("TestConnection() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTestConnectionSuccessAndFailure(t *testing.T) {
|
|
successServer := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: `{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`},
|
|
}, nil)
|
|
t.Cleanup(successServer.Close)
|
|
|
|
successClient := mustClientForServer(t, successServer.URL, ClientConfig{APIKey: "key"})
|
|
if err := successClient.TestConnection(context.Background()); err != nil {
|
|
t.Fatalf("TestConnection() success error = %v", err)
|
|
}
|
|
|
|
failureServer := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {status: http.StatusUnauthorized, body: `{"error":"unauthorized"}`},
|
|
}, nil)
|
|
t.Cleanup(failureServer.Close)
|
|
|
|
failureClient := mustClientForServer(t, failureServer.URL, ClientConfig{APIKey: "bad"})
|
|
if err := failureClient.TestConnection(context.Background()); err == nil {
|
|
t.Fatal("expected TestConnection() failure error")
|
|
}
|
|
}
|
|
|
|
func TestFetchSnapshot(t *testing.T) {
|
|
server := newMockServer(t, defaultAPIResponses(), nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
snapshot, err := client.FetchSnapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("FetchSnapshot() error = %v", err)
|
|
}
|
|
if snapshot == nil {
|
|
t.Fatal("expected snapshot")
|
|
}
|
|
if snapshot.CollectedAt.IsZero() {
|
|
t.Fatal("expected non-zero CollectedAt")
|
|
}
|
|
if snapshot.System.Hostname != "truenas-main" {
|
|
t.Fatalf("unexpected snapshot system: %+v", snapshot.System)
|
|
}
|
|
if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Apps) != 1 {
|
|
t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d",
|
|
len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps))
|
|
}
|
|
if snapshot.Disks[0].Temperature != 34 || snapshot.Disks[1].Temperature != 49 {
|
|
t.Fatalf("unexpected snapshot disk temperatures: %+v", snapshot.Disks)
|
|
}
|
|
if snapshot.Apps[0].ID != "nextcloud" || snapshot.Apps[0].ContainerCount != 2 {
|
|
t.Fatalf("unexpected snapshot apps: %+v", snapshot.Apps)
|
|
}
|
|
}
|
|
|
|
func TestGetAppsEnrichesStatsFromRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
subscribeReq := readRPCRequest(t, conn)
|
|
if subscribeReq.Method != "core.subscribe" {
|
|
t.Fatalf("expected core.subscribe, got %q", subscribeReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, subscribeReq.ID, "sub-1")
|
|
writeRPCNotification(t, conn, "collection_update", map[string]any{
|
|
"collection": "app.stats:{\"interval\":2}",
|
|
"fields": []map[string]any{
|
|
{
|
|
"app_name": "nextcloud",
|
|
"cpu_usage": 17,
|
|
"memory": 268435456,
|
|
"networks": []map[string]any{
|
|
{"interface_name": "eth0", "rx_bytes": 2048, "tx_bytes": 1024},
|
|
{"interface_name": "eth1", "rx_bytes": 512, "tx_bytes": 256},
|
|
},
|
|
"blkio": map[string]any{
|
|
"read": 4096,
|
|
"write": 2048,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
apps, err := client.GetApps(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetApps() error = %v", err)
|
|
}
|
|
if len(apps) != 1 || apps[0].Stats == nil {
|
|
t.Fatalf("expected one app with stats, got %+v", apps)
|
|
}
|
|
if apps[0].Stats.CPUPercent != 17 || apps[0].Stats.MemoryBytes != 268435456 {
|
|
t.Fatalf("unexpected app stats core fields: %+v", apps[0].Stats)
|
|
}
|
|
if apps[0].Stats.NetInRate != 2560 || apps[0].Stats.NetOutRate != 1280 {
|
|
t.Fatalf("unexpected aggregated app network rates: %+v", apps[0].Stats)
|
|
}
|
|
if apps[0].Stats.BlockReadBytes != 4096 || apps[0].Stats.BlockWriteBytes != 2048 {
|
|
t.Fatalf("unexpected app blkio stats: %+v", apps[0].Stats)
|
|
}
|
|
if len(apps[0].Stats.Interfaces) != 2 {
|
|
t.Fatalf("expected two interface stats, got %+v", apps[0].Stats.Interfaces)
|
|
}
|
|
}
|
|
|
|
func TestStartAndStopAppUseRPCMethods(t *testing.T) {
|
|
var rpcCalls int
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
rpcCalls++
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
actionReq := readRPCRequest(t, conn)
|
|
expectedMethod := "app.start"
|
|
if rpcCalls == 2 {
|
|
expectedMethod = "app.stop"
|
|
}
|
|
if actionReq.Method != expectedMethod {
|
|
t.Fatalf("expected %s, got %q", expectedMethod, actionReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, actionReq.ID, true)
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
if err := client.StartApp(context.Background(), "nextcloud"); err != nil {
|
|
t.Fatalf("StartApp() error = %v", err)
|
|
}
|
|
if err := client.StopApp(context.Background(), "nextcloud"); err != nil {
|
|
t.Fatalf("StopApp() error = %v", err)
|
|
}
|
|
if rpcCalls != 2 {
|
|
t.Fatalf("expected two RPC app-action sessions, got %d", rpcCalls)
|
|
}
|
|
}
|
|
|
|
func TestGetAppLogsUsesRPCSubscription(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
subscribeReq := readRPCRequest(t, conn)
|
|
if subscribeReq.Method != "core.subscribe" {
|
|
t.Fatalf("expected core.subscribe, got %q", subscribeReq.Method)
|
|
}
|
|
params, ok := subscribeReq.Params.([]any)
|
|
if !ok || len(params) != 1 {
|
|
t.Fatalf("expected one subscription param, got %#v", subscribeReq.Params)
|
|
}
|
|
subscriptionName, _ := params[0].(string)
|
|
if !strings.HasPrefix(subscriptionName, "app.container_log_follow:") {
|
|
t.Fatalf("expected app.container_log_follow subscription, got %q", subscriptionName)
|
|
}
|
|
if !strings.Contains(subscriptionName, "\"app_name\":\"nextcloud\"") || !strings.Contains(subscriptionName, "\"container_id\":\"nextcloud-web-1\"") {
|
|
t.Fatalf("expected subscription args for nextcloud-web-1, got %q", subscriptionName)
|
|
}
|
|
writeRPCResult(t, conn, subscribeReq.ID, "sub-logs")
|
|
writeRPCNotification(t, conn, "collection_update", map[string]any{
|
|
"collection": "app.container_log_follow:{\"app_name\":\"nextcloud\",\"container_id\":\"nextcloud-web-1\",\"tail_lines\":2}",
|
|
"fields": map[string]any{
|
|
"data": "ready",
|
|
"timestamp": "2026-03-29T18:00:00Z",
|
|
},
|
|
})
|
|
writeRPCNotification(t, conn, "collection_update", map[string]any{
|
|
"collection": "app.container_log_follow:{\"app_name\":\"nextcloud\",\"container_id\":\"nextcloud-web-1\",\"tail_lines\":2}",
|
|
"fields": map[string]any{
|
|
"data": "serving",
|
|
"timestamp": "2026-03-29T18:01:00Z",
|
|
},
|
|
})
|
|
time.Sleep(defaultAppLogIdleWait + 100*time.Millisecond)
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
lines, err := client.GetAppLogs(context.Background(), "nextcloud", "nextcloud-web-1", 2)
|
|
if err != nil {
|
|
t.Fatalf("GetAppLogs() error = %v", err)
|
|
}
|
|
if len(lines) != 2 {
|
|
t.Fatalf("expected two log lines, got %+v", lines)
|
|
}
|
|
if lines[0].Timestamp != "2026-03-29T18:00:00Z" || lines[0].Data != "ready" {
|
|
t.Fatalf("unexpected first log line: %+v", lines[0])
|
|
}
|
|
if lines[1].Data != "serving" {
|
|
t.Fatalf("unexpected second log line: %+v", lines[1])
|
|
}
|
|
}
|
|
|
|
func TestGetSystemTelemetryFromRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
temperatureReq := readRPCRequest(t, conn)
|
|
if temperatureReq.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", temperatureReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, temperatureReq.ID, []map[string]any{{
|
|
"name": "cputemp",
|
|
"identifier": nil,
|
|
"legend": []string{"cpu_package", "core 0", "core 1"},
|
|
"aggregations": map[string]any{
|
|
"mean": map[string]any{
|
|
"cpu_package": 61.5,
|
|
"core 0": 58.0,
|
|
"core 1": 59.0,
|
|
},
|
|
"min": map[string]any{
|
|
"cpu_package": 60.0,
|
|
"core 0": 57.0,
|
|
"core 1": 58.0,
|
|
},
|
|
"max": map[string]any{
|
|
"cpu_package": 63.0,
|
|
"core 0": 60.0,
|
|
"core 1": 61.0,
|
|
},
|
|
},
|
|
"data": []any{},
|
|
"start": time.Now().Add(-5 * time.Minute).Unix(),
|
|
"end": time.Now().Unix(),
|
|
}})
|
|
|
|
subscribeReq := readRPCRequest(t, conn)
|
|
if subscribeReq.Method != "core.subscribe" {
|
|
t.Fatalf("expected core.subscribe, got %q", subscribeReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, subscribeReq.ID, "sub-1")
|
|
writeRPCNotification(t, conn, "collection_update", map[string]any{
|
|
"collection": "reporting.realtime:{\"interval\":2}",
|
|
"fields": map[string]any{
|
|
"cpu": map[string]any{
|
|
"usage": 41,
|
|
},
|
|
"memory": map[string]any{
|
|
"physical_memory_total": 68719476736,
|
|
"physical_memory_available": 21474836480,
|
|
},
|
|
"interfaces": map[string]any{
|
|
"enp1s0": map[string]any{"rx_bytes": 4096, "tx_bytes": 2048},
|
|
"enp2s0": map[string]any{"received_bytes": 1024, "sent_bytes": 512},
|
|
},
|
|
"disks": map[string]any{
|
|
"sda": map[string]any{"read_bytes": 2048, "write_bytes": 1024},
|
|
"nvme0n1": map[string]any{"read_bytes": 4096, "write_bytes": 3072},
|
|
},
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
system, err := client.GetSystemTelemetry(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSystemTelemetry() error = %v", err)
|
|
}
|
|
if system == nil {
|
|
t.Fatal("expected system telemetry")
|
|
}
|
|
if system.CPUPercent != 41 {
|
|
t.Fatalf("expected cpu percent 41, got %+v", system)
|
|
}
|
|
if system.MemoryTotalBytes != 68719476736 || system.MemoryAvailableBytes != 21474836480 {
|
|
t.Fatalf("unexpected memory telemetry: %+v", system)
|
|
}
|
|
if system.NetInRate != 5120 || system.NetOutRate != 2560 {
|
|
t.Fatalf("unexpected network telemetry: %+v", system)
|
|
}
|
|
if system.DiskReadRate != 6144 || system.DiskWriteRate != 4096 {
|
|
t.Fatalf("unexpected disk telemetry: %+v", system)
|
|
}
|
|
if got := system.TemperatureCelsius["cpu_package"]; got != 61.5 {
|
|
t.Fatalf("expected cpu_package temperature 61.5, got %+v", system.TemperatureCelsius)
|
|
}
|
|
if got := system.TemperatureCelsius["cpu_core_0"]; got != 58.0 {
|
|
t.Fatalf("expected cpu_core_0 temperature 58.0, got %+v", system.TemperatureCelsius)
|
|
}
|
|
if system.IntervalSeconds != 2 || system.CollectedAt.IsZero() {
|
|
t.Fatalf("expected interval/collectedAt metadata, got %+v", system)
|
|
}
|
|
}
|
|
|
|
func TestGetSystemMetricHistoryUsesReportingRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
historyReq := readRPCRequest(t, conn)
|
|
if historyReq.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", historyReq.Method)
|
|
}
|
|
params, ok := historyReq.Params.([]any)
|
|
if !ok || len(params) != 2 {
|
|
t.Fatalf("unexpected history params: %#v", historyReq.Params)
|
|
}
|
|
graphs, ok := params[0].([]any)
|
|
if !ok || len(graphs) != 4 {
|
|
t.Fatalf("unexpected history graphs: %#v", params[0])
|
|
}
|
|
query, ok := params[1].(map[string]any)
|
|
if !ok || query["aggregate"] != false {
|
|
t.Fatalf("expected aggregate=false history query, got %#v", params[1])
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
writeRPCResult(t, conn, historyReq.ID, []map[string]any{
|
|
{
|
|
"name": "cpu",
|
|
"identifier": nil,
|
|
"legend": []string{"usage"},
|
|
"data": []any{
|
|
map[string]any{"timestamp": now.Add(-2 * time.Hour).Unix(), "usage": 21.0},
|
|
map[string]any{"timestamp": now.Unix(), "usage": 34.0},
|
|
},
|
|
"aggregations": map[string]any{},
|
|
"start": now.Add(-2 * time.Hour).Unix(),
|
|
"end": now.Unix(),
|
|
},
|
|
{
|
|
"name": "memory",
|
|
"identifier": nil,
|
|
"legend": []string{"used", "total"},
|
|
"data": []any{
|
|
map[string]any{"timestamp": now.Add(-2 * time.Hour).Unix(), "used": 8.0 * 1024 * 1024 * 1024, "total": 16.0 * 1024 * 1024 * 1024},
|
|
map[string]any{"timestamp": now.Unix(), "used": 10.0 * 1024 * 1024 * 1024, "total": 16.0 * 1024 * 1024 * 1024},
|
|
},
|
|
"aggregations": map[string]any{},
|
|
"start": now.Add(-2 * time.Hour).Unix(),
|
|
"end": now.Unix(),
|
|
},
|
|
{
|
|
"name": "interface",
|
|
"identifier": nil,
|
|
"legend": []string{"received", "sent"},
|
|
"data": []any{
|
|
map[string]any{"timestamp": now.Add(-2 * time.Hour).Unix(), "received": 1024.0, "sent": 512.0},
|
|
map[string]any{"timestamp": now.Unix(), "received": 4096.0, "sent": 2048.0},
|
|
},
|
|
"aggregations": map[string]any{},
|
|
"start": now.Add(-2 * time.Hour).Unix(),
|
|
"end": now.Unix(),
|
|
},
|
|
{
|
|
"name": "disk",
|
|
"identifier": nil,
|
|
"legend": []string{"read", "write"},
|
|
"data": []any{
|
|
map[string]any{"timestamp": now.Add(-2 * time.Hour).Unix(), "read": 2048.0, "write": 1024.0},
|
|
map[string]any{"timestamp": now.Unix(), "read": 8192.0, "write": 4096.0},
|
|
},
|
|
"aggregations": map[string]any{},
|
|
"start": now.Add(-2 * time.Hour).Unix(),
|
|
"end": now.Unix(),
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
history, err := client.GetSystemMetricHistory(context.Background(), 4*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GetSystemMetricHistory() error = %v", err)
|
|
}
|
|
if history == nil {
|
|
t.Fatal("expected system metric history")
|
|
}
|
|
if got := len(history.CPUPercent); got != 2 {
|
|
t.Fatalf("expected cpu history, got %+v", history)
|
|
}
|
|
if got := len(history.MemoryPercent); got != 2 {
|
|
t.Fatalf("expected memory percent history, got %+v", history)
|
|
}
|
|
if got := history.MemoryPercent[1].Value; got <= 0 {
|
|
t.Fatalf("expected non-zero memory percent, got %+v", history.MemoryPercent)
|
|
}
|
|
if got := history.NetInRate[1].Value; got != 4096.0 {
|
|
t.Fatalf("expected network history, got %+v", history.NetInRate)
|
|
}
|
|
if got := history.DiskWriteRate[1].Value; got != 4096.0 {
|
|
t.Fatalf("expected disk history, got %+v", history.DiskWriteRate)
|
|
}
|
|
}
|
|
|
|
func TestGetSystemTelemetryIgnoresUnavailableTemperatureRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
temperatureReq := readRPCRequest(t, conn)
|
|
if temperatureReq.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", temperatureReq.Method)
|
|
}
|
|
writeRPCError(t, conn, temperatureReq.ID, -32601, "not found")
|
|
|
|
subscribeReq := readRPCRequest(t, conn)
|
|
if subscribeReq.Method != "core.subscribe" {
|
|
t.Fatalf("expected core.subscribe, got %q", subscribeReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, subscribeReq.ID, "sub-1")
|
|
writeRPCNotification(t, conn, "collection_update", map[string]any{
|
|
"collection": "reporting.realtime:{\"interval\":2}",
|
|
"fields": map[string]any{
|
|
"cpu": map[string]any{"usage": 41},
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
system, err := client.GetSystemTelemetry(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSystemTelemetry() error = %v", err)
|
|
}
|
|
if system == nil {
|
|
t.Fatal("expected system telemetry")
|
|
}
|
|
if system.CPUPercent != 41 {
|
|
t.Fatalf("expected cpu percent 41, got %+v", system)
|
|
}
|
|
if len(system.TemperatureCelsius) != 0 {
|
|
t.Fatalf("expected unavailable temperature RPC to be ignored, got %+v", system.TemperatureCelsius)
|
|
}
|
|
}
|
|
|
|
func TestGetDiskTemperaturesSupportsArrayShape(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/disk/temperatures": {
|
|
body: `[{"name":"sda","temperature":33},{"identifier":"{disk-2}","temperature_celsius":"48"},{"serial":"SER-C","temperature":{"parsed":52}}]`,
|
|
},
|
|
}, nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
temperatures, err := client.GetDiskTemperatures(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetDiskTemperatures() error = %v", err)
|
|
}
|
|
if got := temperatures["sda"]; got != 33 {
|
|
t.Fatalf("expected sda temperature 33, got %d", got)
|
|
}
|
|
if got := temperatures["{disk-2}"]; got != 48 {
|
|
t.Fatalf("expected {disk-2} temperature 48, got %d", got)
|
|
}
|
|
if got := temperatures["SER-C"]; got != 52 {
|
|
t.Fatalf("expected SER-C temperature 52, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestGetDisksToleratesUnavailableTemperatureEndpoint(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/disk": {
|
|
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
|
},
|
|
"/api/v2.0/disk/temperatures": {
|
|
status: http.StatusNotFound,
|
|
body: `{"error":"not found"}`,
|
|
},
|
|
}, nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
disks, err := client.GetDisks(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetDisks() error = %v", err)
|
|
}
|
|
if len(disks) != 1 {
|
|
t.Fatalf("expected 1 disk, got %d", len(disks))
|
|
}
|
|
if disks[0].Temperature != 0 {
|
|
t.Fatalf("expected unavailable temperature to stay empty, got %+v", disks[0])
|
|
}
|
|
}
|
|
|
|
func TestGetDiskTemperaturesFallsBackToReportingRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, map[string]apiResponse{
|
|
"/api/v2.0/disk": {
|
|
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
|
},
|
|
"/api/v2.0/disk/temperatures": {
|
|
status: http.StatusNotFound,
|
|
body: `{"error":"not found"}`,
|
|
},
|
|
}, nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
temperatureReq := readRPCRequest(t, conn)
|
|
if temperatureReq.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", temperatureReq.Method)
|
|
}
|
|
params, ok := temperatureReq.Params.([]any)
|
|
if !ok || len(params) != 2 {
|
|
t.Fatalf("unexpected reporting params: %#v", temperatureReq.Params)
|
|
}
|
|
graphs, ok := params[0].([]any)
|
|
if !ok || len(graphs) != 1 {
|
|
t.Fatalf("unexpected reporting graphs: %#v", params[0])
|
|
}
|
|
graph, ok := graphs[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("unexpected reporting graph entry: %#v", graphs[0])
|
|
}
|
|
if got := readStringAny(graph, "name"); got != "disktemp" {
|
|
t.Fatalf("expected disktemp graph, got %q", got)
|
|
}
|
|
if got := readStringAny(graph, "identifier"); got != "sda" {
|
|
t.Fatalf("expected sda identifier, got %q", got)
|
|
}
|
|
writeRPCResult(t, conn, temperatureReq.ID, []map[string]any{{
|
|
"name": "disktemp",
|
|
"identifier": "sda",
|
|
"legend": []string{"temperature"},
|
|
"aggregations": map[string]any{
|
|
"mean": map[string]any{
|
|
"temperature": 41.8,
|
|
},
|
|
},
|
|
"data": []any{},
|
|
"start": time.Now().Add(-5 * time.Minute).Unix(),
|
|
"end": time.Now().Unix(),
|
|
}})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
temperatures, err := client.GetDiskTemperatures(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetDiskTemperatures() error = %v", err)
|
|
}
|
|
if got := temperatures["sda"]; got != 42 {
|
|
t.Fatalf("expected sda reporting fallback temperature 42, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *testing.T) {
|
|
connectionCount := 0
|
|
server := newMockServerWithRPC(t, map[string]apiResponse{
|
|
"/api/v2.0/disk": {
|
|
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
|
},
|
|
"/api/v2.0/disk/temperatures": {
|
|
status: http.StatusNotFound,
|
|
body: `{"error":"not found"}`,
|
|
},
|
|
}, nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
connectionCount++
|
|
request := readRPCRequest(t, conn)
|
|
switch connectionCount {
|
|
case 1:
|
|
if request.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", request.Method)
|
|
}
|
|
writeRPCResult(t, conn, request.ID, []map[string]any{{
|
|
"name": "disktemp",
|
|
"identifier": "sda",
|
|
"legend": []string{"temperature"},
|
|
"aggregations": map[string]any{
|
|
"mean": map[string]any{
|
|
"temperature": 43.2,
|
|
},
|
|
},
|
|
"data": []any{},
|
|
"start": time.Now().Add(-5 * time.Minute).Unix(),
|
|
"end": time.Now().Unix(),
|
|
}})
|
|
case 2:
|
|
if request.Method != "disk.temperature_agg" {
|
|
t.Fatalf("expected disk.temperature_agg, got %q", request.Method)
|
|
}
|
|
writeRPCResult(t, conn, request.ID, map[string]any{
|
|
"sda": map[string]any{
|
|
"min": 39.0,
|
|
"avg": 41.6,
|
|
"max": 45.0,
|
|
"window_days": 7,
|
|
},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected extra websocket connection %d", connectionCount)
|
|
}
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
disks, err := client.GetDisks(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetDisks() error = %v", err)
|
|
}
|
|
if len(disks) != 1 {
|
|
t.Fatalf("expected 1 disk, got %d", len(disks))
|
|
}
|
|
if got := disks[0].Temperature; got != 43 {
|
|
t.Fatalf("expected reporting fallback temperature 43, got %+v", disks[0])
|
|
}
|
|
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 45.0 {
|
|
t.Fatalf("expected aggregate max 45.0, got %+v", disks[0].TemperatureAggregate)
|
|
}
|
|
}
|
|
|
|
func TestGetDisksIncludesDiskTemperatureAggregatesFromRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, map[string]apiResponse{
|
|
"/api/v2.0/disk": {
|
|
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
|
},
|
|
"/api/v2.0/disk/temperatures": {
|
|
body: `{"sda":34}`,
|
|
},
|
|
}, nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
aggregateReq := readRPCRequest(t, conn)
|
|
if aggregateReq.Method != "disk.temperature_agg" {
|
|
t.Fatalf("expected disk.temperature_agg, got %q", aggregateReq.Method)
|
|
}
|
|
params, ok := aggregateReq.Params.([]any)
|
|
if !ok || len(params) != 2 {
|
|
t.Fatalf("unexpected aggregate params: %#v", aggregateReq.Params)
|
|
}
|
|
identifiers, ok := params[0].([]any)
|
|
if !ok || len(identifiers) != 1 {
|
|
t.Fatalf("unexpected aggregate identifiers: %#v", params[0])
|
|
}
|
|
if got := strings.TrimSpace(fmt.Sprint(identifiers[0])); got != "sda" {
|
|
t.Fatalf("expected sda identifier, got %q", got)
|
|
}
|
|
if got := int(readFloatAny(map[string]any{"value": params[1]}, "value")); got != defaultDiskTemperatureAggregateWindowDays {
|
|
t.Fatalf("expected window %d, got %#v", defaultDiskTemperatureAggregateWindowDays, params[1])
|
|
}
|
|
writeRPCResult(t, conn, aggregateReq.ID, map[string]any{
|
|
"sda": map[string]any{
|
|
"min": 29.0,
|
|
"avg": 32.8,
|
|
"max": 38.0,
|
|
"window_days": 7,
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
disks, err := client.GetDisks(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetDisks() error = %v", err)
|
|
}
|
|
if len(disks) != 1 {
|
|
t.Fatalf("expected 1 disk, got %d", len(disks))
|
|
}
|
|
if got := disks[0].TemperatureAggregate.WindowDays; got != 7 {
|
|
t.Fatalf("expected aggregate window 7, got %+v", disks[0].TemperatureAggregate)
|
|
}
|
|
if got := disks[0].TemperatureAggregate.MinCelsius; got != 29.0 {
|
|
t.Fatalf("expected aggregate min 29.0, got %+v", disks[0].TemperatureAggregate)
|
|
}
|
|
if got := disks[0].TemperatureAggregate.AvgCelsius; got != 32.8 {
|
|
t.Fatalf("expected aggregate avg 32.8, got %+v", disks[0].TemperatureAggregate)
|
|
}
|
|
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 38.0 {
|
|
t.Fatalf("expected aggregate max 38.0, got %+v", disks[0].TemperatureAggregate)
|
|
}
|
|
}
|
|
|
|
func TestGetDiskTemperatureHistoryUsesReportingRPC(t *testing.T) {
|
|
server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) {
|
|
authReq := readRPCRequest(t, conn)
|
|
if authReq.Method != "auth.login_with_api_key" {
|
|
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
|
}
|
|
writeRPCResult(t, conn, authReq.ID, true)
|
|
|
|
historyReq := readRPCRequest(t, conn)
|
|
if historyReq.Method != "reporting.get_data" {
|
|
t.Fatalf("expected reporting.get_data, got %q", historyReq.Method)
|
|
}
|
|
params, ok := historyReq.Params.([]any)
|
|
if !ok || len(params) != 2 {
|
|
t.Fatalf("unexpected history params: %#v", historyReq.Params)
|
|
}
|
|
graphs, ok := params[0].([]any)
|
|
if !ok || len(graphs) != 1 {
|
|
t.Fatalf("unexpected history graphs: %#v", params[0])
|
|
}
|
|
graph, ok := graphs[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("unexpected history graph entry: %#v", graphs[0])
|
|
}
|
|
if got := readStringAny(graph, "name"); got != "disktemp" {
|
|
t.Fatalf("expected disktemp graph, got %q", got)
|
|
}
|
|
if got := readStringAny(graph, "identifier"); got != "sda" {
|
|
t.Fatalf("expected sda identifier, got %q", got)
|
|
}
|
|
query, ok := params[1].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("unexpected history query: %#v", params[1])
|
|
}
|
|
if aggregate := query["aggregate"]; aggregate != false {
|
|
t.Fatalf("expected aggregate=false for history query, got %#v", aggregate)
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
writeRPCResult(t, conn, historyReq.ID, []map[string]any{{
|
|
"name": "disktemp",
|
|
"identifier": "sda",
|
|
"legend": []string{"temperature"},
|
|
"data": []any{
|
|
[]any{now.Add(-2 * time.Hour).Unix(), 30.0},
|
|
[]any{now.Add(-1 * time.Hour).Unix(), 31.5},
|
|
[]any{now.Unix(), 33.0},
|
|
},
|
|
"aggregations": map[string]any{},
|
|
"start": now.Add(-2 * time.Hour).Unix(),
|
|
"end": now.Unix(),
|
|
}})
|
|
})
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
|
history, err := client.GetDiskTemperatureHistory(context.Background(), []string{"sda"}, 4*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GetDiskTemperatureHistory() error = %v", err)
|
|
}
|
|
points, ok := history["sda"]
|
|
if !ok {
|
|
t.Fatalf("expected history for sda, got %#v", history)
|
|
}
|
|
if len(points) != 3 {
|
|
t.Fatalf("expected 3 history points, got %+v", points)
|
|
}
|
|
if points[0].Value != 30.0 || points[2].Value != 33.0 {
|
|
t.Fatalf("unexpected history values: %+v", points)
|
|
}
|
|
}
|
|
|
|
func TestClientHandlesHTTPAndDecodeErrors(t *testing.T) {
|
|
t.Run("non-2xx response", func(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/pool": {status: http.StatusServiceUnavailable, body: `{"error":"down"}`},
|
|
}, nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "key"})
|
|
_, err := client.GetPools(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error from non-2xx response")
|
|
}
|
|
if !strings.Contains(err.Error(), "status=503") {
|
|
t.Fatalf("expected status code in error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed json", func(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: `{"hostname":`},
|
|
}, nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "key"})
|
|
_, err := client.GetSystemInfo(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected malformed json error")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode truenas response") {
|
|
t.Fatalf("unexpected decode error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("connection failure", func(t *testing.T) {
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: `{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`},
|
|
}, nil)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "key"})
|
|
server.Close()
|
|
|
|
_, err := client.GetSystemInfo(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected connection error")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed") {
|
|
t.Fatalf("unexpected connection error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClientRejectsOversizedJSONResponses(t *testing.T) {
|
|
oversizedBody := fmt.Sprintf(
|
|
`{"hostname":"%s","version":"v","buildtime":"b","uptime_seconds":1}`,
|
|
strings.Repeat("a", int(maxResponseBodyBytes)),
|
|
)
|
|
server := newMockServer(t, map[string]apiResponse{
|
|
"/api/v2.0/system/info": {body: oversizedBody},
|
|
}, nil)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "key"})
|
|
_, err := client.GetSystemInfo(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected oversized response error")
|
|
}
|
|
if !strings.Contains(err.Error(), fmt.Sprintf("response body exceeds %d bytes", maxResponseBodyBytes)) {
|
|
t.Fatalf("expected response size limit error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewClientRejectsUnsafeURLComponents(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "credentials",
|
|
host: "https://admin:secret@truenas.local",
|
|
errContains: "credentials are not supported",
|
|
},
|
|
{
|
|
name: "non-root path",
|
|
host: "https://truenas.local/api/v2.0",
|
|
errContains: "path is not supported",
|
|
},
|
|
{
|
|
name: "query",
|
|
host: "https://truenas.local?insecure=1",
|
|
errContains: "query is not supported",
|
|
},
|
|
{
|
|
name: "fragment",
|
|
host: "https://truenas.local#section",
|
|
errContains: "fragment is not supported",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := NewClient(ClientConfig{
|
|
Host: tt.host,
|
|
APIKey: "key",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error for host %q", tt.host)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Fatalf("expected error containing %q, got %v", tt.errContains, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientTLSFingerprintPinning(t *testing.T) {
|
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/api/v2.0/system/info" {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`))
|
|
})
|
|
|
|
tlsServer := httptest.NewTLSServer(handler)
|
|
t.Cleanup(tlsServer.Close)
|
|
|
|
cert := tlsServer.Certificate()
|
|
fingerprintRaw := sha256.Sum256(cert.Raw)
|
|
fingerprint := withFingerprintColons(strings.ToUpper(hex.EncodeToString(fingerprintRaw[:])))
|
|
|
|
client := mustClientForServer(t, tlsServer.URL, ClientConfig{
|
|
APIKey: "key",
|
|
InsecureSkipVerify: true,
|
|
Fingerprint: "SHA256:" + fingerprint,
|
|
})
|
|
if err := client.TestConnection(context.Background()); err != nil {
|
|
t.Fatalf("expected pinning success, got %v", err)
|
|
}
|
|
|
|
badClient := mustClientForServer(t, tlsServer.URL, ClientConfig{
|
|
APIKey: "key",
|
|
InsecureSkipVerify: true,
|
|
Fingerprint: strings.Repeat("0", 64),
|
|
})
|
|
if err := badClient.TestConnection(context.Background()); err == nil {
|
|
t.Fatal("expected pinning failure")
|
|
}
|
|
}
|
|
|
|
func TestClientCloseClosesIdleConnections(t *testing.T) {
|
|
transport := &closeTrackingTransport{}
|
|
client := &Client{
|
|
httpClient: &http.Client{
|
|
Transport: transport,
|
|
},
|
|
}
|
|
|
|
client.Close()
|
|
if transport.closeCalls != 1 {
|
|
t.Fatalf("expected CloseIdleConnections to be called once, got %d", transport.closeCalls)
|
|
}
|
|
}
|
|
|
|
func TestClientCloseNilSafe(t *testing.T) {
|
|
var nilClient *Client
|
|
nilClient.Close()
|
|
|
|
(&Client{}).Close()
|
|
(&Client{httpClient: &http.Client{}}).Close()
|
|
}
|
|
|
|
func defaultAPIResponses() map[string]apiResponse {
|
|
return map[string]apiResponse{
|
|
"/api/v2.0/system/info": {
|
|
body: `{"hostname":"truenas-main","version":"TrueNAS-SCALE-24.10.2","buildtime":"24.10.2.1","uptime_seconds":86400,"system_serial":"SER123","system_manufacturer":"iXsystems","physical_cores":16,"physmem":68719476736}`,
|
|
},
|
|
"/api/v2.0/pool": {
|
|
body: `[{"id":1,"name":"tank","status":"ONLINE","size":1000,"allocated":400,"free":600}]`,
|
|
},
|
|
"/api/v2.0/pool/dataset": {
|
|
body: `[{"id":"tank/apps","name":"tank/apps","pool":"tank","used":{"rawvalue":"12345","parsed":12345},"available":{"rawvalue":"555","parsed":555},"mountpoint":"/mnt/tank/apps","readonly":{"rawvalue":"off","parsed":false},"mounted":true}]`,
|
|
},
|
|
"/api/v2.0/disk": {
|
|
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"},{"identifier":"{disk-2}","name":"nvme0n1","serial":"SER-B","size":2000000,"model":"Samsung","type":"SSD","pool":"tank","bus":"NVMe","rotationrate":0,"status":"ONLINE"}]`,
|
|
},
|
|
"/api/v2.0/disk/temperatures": {
|
|
body: `{"sda":34,"nvme0n1":"49","SER-B":51}`,
|
|
},
|
|
"/api/v2.0/alert/list": {
|
|
body: `[{"id":"a1","level":"WARNING","formatted":"Disk temp high","source":"DiskService","dismissed":false,"datetime":{"$date":1707400000000}}]`,
|
|
},
|
|
"/api/v2.0/app": {
|
|
body: `[{"id":"nextcloud","name":"Nextcloud","state":"RUNNING","version":"1.0.3","human_version":"29.0.7","upgrade_available":true,"image_updates_available":true,"notes":"Team cloud","active_workloads":{"containers":2,"used_host_ips":["0.0.0.0"],"used_ports":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"container_details":[{"id":"nextcloud-web-1","service_name":"nextcloud","image":"docker.io/library/nextcloud:29.0.7","state":"running","port_config":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"volume_mounts":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"}]},{"id":"nextcloud-redis-1","service_name":"redis","image":"docker.io/library/redis:7.2","state":"running","port_config":[],"volume_mounts":[{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}]}],"volumes":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"},{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}],"images":["docker.io/library/nextcloud:29.0.7","docker.io/library/redis:7.2"],"networks":[{"name":"ix-nextcloud_default","id":"net-1","labels":{"com.docker.compose.project":"nextcloud"}}]}}]`,
|
|
},
|
|
}
|
|
}
|
|
|
|
func newMockServer(t *testing.T, responses map[string]apiResponse, assertRequest func(*testing.T, *http.Request)) *httptest.Server {
|
|
t.Helper()
|
|
|
|
return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if assertRequest != nil {
|
|
assertRequest(t, request)
|
|
}
|
|
|
|
response, ok := responses[request.URL.Path]
|
|
if !ok {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
|
|
status := response.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
contentType := response.contentType
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", contentType)
|
|
writer.WriteHeader(status)
|
|
_, _ = writer.Write([]byte(response.body))
|
|
}))
|
|
}
|
|
|
|
func newMockServerWithRPC(t *testing.T, responses map[string]apiResponse, assertRequest func(*testing.T, *http.Request), handleRPC func(*testing.T, *websocket.Conn)) *httptest.Server {
|
|
t.Helper()
|
|
|
|
upgrader := websocket.Upgrader{}
|
|
return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path == "/api/current" && websocket.IsWebSocketUpgrade(request) {
|
|
conn, err := upgrader.Upgrade(writer, request, nil)
|
|
if err != nil {
|
|
t.Fatalf("upgrade websocket: %v", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
handleRPC(t, conn)
|
|
return
|
|
}
|
|
|
|
if assertRequest != nil {
|
|
assertRequest(t, request)
|
|
}
|
|
|
|
response, ok := responses[request.URL.Path]
|
|
if !ok {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
|
|
status := response.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
contentType := response.contentType
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", contentType)
|
|
writer.WriteHeader(status)
|
|
_, _ = writer.Write([]byte(response.body))
|
|
}))
|
|
}
|
|
|
|
func readRPCRequest(t *testing.T, conn *websocket.Conn) trueNASRPCRequest {
|
|
t.Helper()
|
|
|
|
var request trueNASRPCRequest
|
|
if err := conn.ReadJSON(&request); err != nil {
|
|
t.Fatalf("ReadJSON() rpc request error = %v", err)
|
|
}
|
|
return request
|
|
}
|
|
|
|
func writeRPCResult(t *testing.T, conn *websocket.Conn, id int64, result any) {
|
|
t.Helper()
|
|
|
|
raw, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() rpc result error = %v", err)
|
|
}
|
|
if err := conn.WriteJSON(trueNASRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Result: raw,
|
|
}); err != nil {
|
|
t.Fatalf("WriteJSON() rpc result error = %v", err)
|
|
}
|
|
}
|
|
|
|
func writeRPCError(t *testing.T, conn *websocket.Conn, id int64, code int, message string) {
|
|
t.Helper()
|
|
|
|
if err := conn.WriteJSON(trueNASRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Error: &trueNASRPCError{
|
|
Code: code,
|
|
Message: message,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("WriteJSON() rpc error response = %v", err)
|
|
}
|
|
}
|
|
|
|
func writeRPCNotification(t *testing.T, conn *websocket.Conn, method string, params any) {
|
|
t.Helper()
|
|
|
|
raw, err := json.Marshal(params)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() rpc notification params error = %v", err)
|
|
}
|
|
if err := conn.WriteJSON(trueNASRPCResponse{
|
|
JSONRPC: "2.0",
|
|
Method: method,
|
|
Params: raw,
|
|
}); err != nil {
|
|
t.Fatalf("WriteJSON() rpc notification error = %v", err)
|
|
}
|
|
}
|
|
|
|
func mustClientForServer(t *testing.T, serverURL string, config ClientConfig) *Client {
|
|
t.Helper()
|
|
|
|
parsed, err := url.Parse(serverURL)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse server URL %q: %v", serverURL, err)
|
|
}
|
|
port, err := strconv.Atoi(parsed.Port())
|
|
if err != nil {
|
|
t.Fatalf("failed to parse server port from %q: %v", serverURL, err)
|
|
}
|
|
|
|
config.Host = parsed.Hostname()
|
|
config.Port = port
|
|
config.UseHTTPS = parsed.Scheme == "https"
|
|
|
|
client, err := NewClient(config)
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func withFingerprintColons(value string) string {
|
|
if len(value)%2 != 0 {
|
|
return value
|
|
}
|
|
parts := make([]string, 0, len(value)/2)
|
|
for i := 0; i < len(value); i += 2 {
|
|
parts = append(parts, value[i:i+2])
|
|
}
|
|
return strings.Join(parts, ":")
|
|
}
|