soteria/internal/longhorn/client_test.go

608 lines
22 KiB
Go

package longhorn
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewTrimsBaseURLAndSetsDefaultTimeout(t *testing.T) {
client := New("http://longhorn.example.test/")
if client.baseURL != "http://longhorn.example.test" {
t.Fatalf("expected trimmed base URL, got %q", client.baseURL)
}
if client.http == nil {
t.Fatalf("expected HTTP client to be initialized")
}
if client.http.Timeout != defaultTimeout {
t.Fatalf("expected timeout %s, got %s", defaultTimeout, client.http.Timeout)
}
}
func TestAPIErrorErrorFormatsStatusAndMessage(t *testing.T) {
err := (&APIError{Status: http.StatusBadGateway, Message: "proxy exploded"}).Error()
if err != "longhorn api error: status=502 message=proxy exploded" {
t.Fatalf("unexpected API error string: %q", err)
}
}
func TestCoreVolumeOperationsUseExpectedRequests(t *testing.T) {
ctx := context.Background()
type requestRecord struct {
method string
path string
query string
body string
}
var requests []requestRecord
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests = append(requests, requestRecord{
method: r.Method,
path: r.URL.Path,
query: r.URL.RawQuery,
body: string(body),
})
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/volumes/pv/name" && r.URL.RawQuery == "action=snapshotCreate":
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/volumes/pv/name" && r.URL.RawQuery == "action=snapshotBackup":
_, _ = w.Write([]byte(`{"name":"pv/name","size":"1073741824","numberOfReplicas":2}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/volumes/pv/name":
_, _ = w.Write([]byte(`{"name":"pv/name","size":"2048","numberOfReplicas":3}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/volumes" && r.URL.RawQuery == "":
_, _ = w.Write([]byte(`{"name":"restored","size":"4096","numberOfReplicas":2}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/volumes/pv/name" && r.URL.RawQuery == "action=pvcCreate":
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodDelete && r.URL.Path == "/v1/volumes/pv/name":
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected request: %s %s?%s body=%s", r.Method, r.URL.Path, r.URL.RawQuery, string(body))
}
}))
defer server.Close()
client := New(server.URL)
if err := client.CreateSnapshot(ctx, "pv/name", "snap-1", map[string]string{"env": "dev"}); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
backupVolume, err := client.SnapshotBackup(ctx, "pv/name", "snap-1", map[string]string{"env": "dev"}, "incremental")
if err != nil {
t.Fatalf("SnapshotBackup: %v", err)
}
if backupVolume.Name != "pv/name" || backupVolume.NumberOfReplicas != 2 {
t.Fatalf("unexpected snapshot backup response: %#v", backupVolume)
}
volume, err := client.GetVolume(ctx, "pv/name")
if err != nil {
t.Fatalf("GetVolume: %v", err)
}
if volume.Name != "pv/name" || volume.NumberOfReplicas != 3 {
t.Fatalf("unexpected volume response: %#v", volume)
}
restored, err := client.CreateVolumeFromBackup(ctx, "restored", "4096", 2, "s3://bucket/backup")
if err != nil {
t.Fatalf("CreateVolumeFromBackup: %v", err)
}
if restored.Name != "restored" || restored.NumberOfReplicas != 2 {
t.Fatalf("unexpected restored volume response: %#v", restored)
}
if err := client.CreatePVC(ctx, "pv/name", "apps", "data"); err != nil {
t.Fatalf("CreatePVC: %v", err)
}
if err := client.DeleteVolume(ctx, "pv/name"); err != nil {
t.Fatalf("DeleteVolume: %v", err)
}
if len(requests) != 6 {
t.Fatalf("expected 6 requests, got %#v", requests)
}
assertBodyContains(t, requests[0].body, `"name":"snap-1"`)
assertBodyContains(t, requests[0].body, `"labels":{"env":"dev"}`)
assertBodyContains(t, requests[1].body, `"backupMode":"incremental"`)
assertBodyContains(t, requests[3].body, `"fromBackup":"s3://bucket/backup"`)
assertBodyContains(t, requests[3].body, `"numberOfReplicas":2`)
assertBodyContains(t, requests[4].body, `"namespace":"apps"`)
assertBodyContains(t, requests[4].body, `"pvcName":"data"`)
}
func TestCreateVolumeFromBackupOmitsReplicaCountWhenNotRequested(t *testing.T) {
ctx := context.Background()
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/volumes" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
raw, _ := io.ReadAll(r.Body)
body = string(raw)
_, _ = w.Write([]byte(`{"name":"restored","size":"4096"}`))
}))
defer server.Close()
client := New(server.URL)
if _, err := client.CreateVolumeFromBackup(ctx, "restored", "4096", 0, "s3://bucket/backup"); err != nil {
t.Fatalf("CreateVolumeFromBackup: %v", err)
}
if strings.Contains(body, "numberOfReplicas") {
t.Fatalf("expected zero-replica request to omit numberOfReplicas, body=%s", body)
}
}
func TestVolumeOperationsPropagateAPIErrorsAndOptionalBranches(t *testing.T) {
ctx := context.Background()
t.Run("snapshot backup without backup mode", func(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/volumes/pv/name" || r.URL.RawQuery != "action=snapshotBackup" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
raw, _ := io.ReadAll(r.Body)
body = string(raw)
_, _ = w.Write([]byte(`{"name":"pv/name","size":"1024"}`))
}))
defer server.Close()
client := New(server.URL)
if _, err := client.SnapshotBackup(ctx, "pv/name", "snap-plain", map[string]string{"team": "atlas"}, ""); err != nil {
t.Fatalf("SnapshotBackup without backup mode: %v", err)
}
if strings.Contains(body, "backupMode") {
t.Fatalf("expected empty backup mode to be omitted, body=%s", body)
}
})
t.Run("get volume error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "volume missing", http.StatusNotFound)
}))
defer server.Close()
client := New(server.URL)
_, err := client.GetVolume(ctx, "pv/name")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusNotFound {
t.Fatalf("expected APIError 404 for GetVolume, got %#v", err)
}
})
t.Run("snapshot backup api error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "snapshot failed", http.StatusBadGateway)
}))
defer server.Close()
client := New(server.URL)
_, err := client.SnapshotBackup(ctx, "pv/name", "snap-1", map[string]string{"env": "dev"}, "incremental")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected APIError 502 for SnapshotBackup, got %#v", err)
}
})
t.Run("create volume from backup error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "create failed", http.StatusBadGateway)
}))
defer server.Close()
client := New(server.URL)
_, err := client.CreateVolumeFromBackup(ctx, "restored", "4096", 1, "s3://bucket/backup")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected APIError 502 for CreateVolumeFromBackup, got %#v", err)
}
})
}
func TestGetBackupVolumeUsesDirectAndFallbackLookup(t *testing.T) {
ctx := context.Background()
t.Run("direct hit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/backupvolumes/vol-a" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"http://example.test/list"}}`))
}))
defer server.Close()
client := New(server.URL)
volume, err := client.GetBackupVolume(ctx, "vol-a")
if err != nil {
t.Fatalf("GetBackupVolume direct hit: %v", err)
}
if volume.Name != "vol-a" || volume.Actions["backupList"] != "http://example.test/list" {
t.Fatalf("unexpected backup volume: %#v", volume)
}
})
t.Run("fallback list", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes/vol-b":
http.Error(w, "missing", http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes":
_, _ = w.Write([]byte(`{"data":[{"name":"vol-b","actions":{"backupList":"http://example.test/list-b"}}]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := New(server.URL)
volume, err := client.GetBackupVolume(ctx, "vol-b")
if err != nil {
t.Fatalf("GetBackupVolume fallback: %v", err)
}
if volume.Name != "vol-b" {
t.Fatalf("expected fallback lookup to find vol-b, got %#v", volume)
}
})
t.Run("fallback missing item", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes/vol-c":
http.Error(w, "missing", http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes":
_, _ = w.Write([]byte(`{"data":[{"name":"other","actions":{"backupList":"http://example.test/list"}}]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := New(server.URL)
_, err := client.GetBackupVolume(ctx, "vol-c")
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected missing fallback item error, got %v", err)
}
})
t.Run("non-404 direct error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "controller unhappy", http.StatusBadGateway)
}))
defer server.Close()
client := New(server.URL)
_, err := client.GetBackupVolume(ctx, "vol-d")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected direct APIError 502, got %#v", err)
}
})
t.Run("fallback list error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes/vol-e":
http.Error(w, "missing", http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes":
http.Error(w, "list failed", http.StatusBadGateway)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := New(server.URL)
_, err := client.GetBackupVolume(ctx, "vol-e")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected fallback list APIError 502, got %#v", err)
}
})
}
func TestListBackupsAndFindBackupSelections(t *testing.T) {
ctx := context.Background()
serverURL := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/backupvolumes/vol-a":
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"` + serverURL + `/backups"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/backups":
_, _ = w.Write([]byte(`{"data":[
{"name":"backup-old","snapshotName":"snap-old","volumeName":"vol-a","created":"2026-04-19T10:00:00Z","state":"Completed","url":"s3://bucket/old","size":"1Gi"},
{"name":"backup-new","snapshotName":"snap-new","volumeName":"vol-a","created":"2026-04-20T10:00:00Z","state":"Completed","url":"s3://bucket/new","size":"2Gi"},
{"name":"backup-invalid","snapshotName":"snap-invalid","volumeName":"vol-a","created":"not-a-time","state":"Completed","url":"s3://bucket/invalid","size":"3Gi"},
{"name":"backup-failed","snapshotName":"snap-failed","volumeName":"vol-a","created":"2026-04-21T10:00:00Z","state":"Failed","url":"s3://bucket/failed","size":"4Gi"}
]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
serverURL = server.URL
client := New(server.URL)
backups, err := client.ListBackups(ctx, "vol-a")
if err != nil {
t.Fatalf("ListBackups: %v", err)
}
if len(backups) != 4 {
t.Fatalf("expected four backups, got %#v", backups)
}
latest, err := client.FindBackup(ctx, "vol-a", "latest")
if err != nil {
t.Fatalf("FindBackup latest: %v", err)
}
if latest.Name != "backup-new" {
t.Fatalf("expected latest completed backup-new, got %#v", latest)
}
bySnapshot, err := client.FindBackup(ctx, "vol-a", "snap-old")
if err != nil {
t.Fatalf("FindBackup by snapshot: %v", err)
}
if bySnapshot.Name != "backup-old" {
t.Fatalf("expected snapshot lookup to return backup-old, got %#v", bySnapshot)
}
byName, err := client.FindBackup(ctx, "vol-a", "backup-new")
if err != nil {
t.Fatalf("FindBackup by name: %v", err)
}
if byName.Name != "backup-new" {
t.Fatalf("expected name lookup to return backup-new, got %#v", byName)
}
byURL, err := client.FindBackup(ctx, "vol-a", "s3://bucket/invalid")
if err != nil {
t.Fatalf("FindBackup by URL: %v", err)
}
if byURL.Name != "backup-invalid" {
t.Fatalf("expected URL lookup to return backup-invalid, got %#v", byURL)
}
}
func TestListBackupsAndFindBackupErrors(t *testing.T) {
ctx := context.Background()
t.Run("missing backupList action", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{}}`))
}))
defer server.Close()
client := New(server.URL)
_, err := client.ListBackups(ctx, "vol-a")
if err == nil || !strings.Contains(err.Error(), "backup list action missing") {
t.Fatalf("expected missing backupList action error, got %v", err)
}
})
t.Run("backup volume lookup error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "volume lookup failed", http.StatusBadGateway)
}))
defer server.Close()
client := New(server.URL)
_, err := client.ListBackups(ctx, "vol-a")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected backup volume lookup APIError 502, got %#v", err)
}
})
t.Run("backup list request fails", func(t *testing.T) {
serverURL := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/backupvolumes/vol-a":
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"` + serverURL + `/backups"}}`))
case "/backups":
http.Error(w, "list failed", http.StatusBadGateway)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
serverURL = server.URL
client := New(server.URL)
_, err := client.ListBackups(ctx, "vol-a")
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway {
t.Fatalf("expected backup list APIError 502, got %#v", err)
}
})
t.Run("no backups found", func(t *testing.T) {
serverURL := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/backupvolumes/vol-a":
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"` + serverURL + `/backups"}}`))
case "/backups":
_, _ = w.Write([]byte(`{"data":[]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
serverURL = server.URL
client := New(server.URL)
_, err := client.FindBackup(ctx, "vol-a", "latest")
if err == nil || !strings.Contains(err.Error(), "no backups found") {
t.Fatalf("expected no backups found error, got %v", err)
}
})
t.Run("named backup not found", func(t *testing.T) {
serverURL := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/backupvolumes/vol-a":
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"` + serverURL + `/backups"}}`))
case "/backups":
_, _ = w.Write([]byte(`{"data":[{"name":"backup-a","snapshotName":"snap-a","volumeName":"vol-a","created":"2026-04-20T10:00:00Z","state":"Completed","url":"s3://bucket/a","size":"1Gi"}]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
serverURL = server.URL
client := New(server.URL)
_, err := client.FindBackup(ctx, "vol-a", "missing")
if err == nil || !strings.Contains(err.Error(), "backup missing not found") {
t.Fatalf("expected named backup missing error, got %v", err)
}
})
t.Run("no completed backups", func(t *testing.T) {
serverURL := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/backupvolumes/vol-a":
_, _ = w.Write([]byte(`{"name":"vol-a","actions":{"backupList":"` + serverURL + `/backups"}}`))
case "/backups":
_, _ = w.Write([]byte(`{"data":[{"name":"backup-a","snapshotName":"snap-a","volumeName":"vol-a","created":"2026-04-20T10:00:00Z","state":"Failed","url":"s3://bucket/a","size":"1Gi"}]}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
serverURL = server.URL
client := New(server.URL)
_, err := client.FindBackup(ctx, "vol-a", "latest")
if err == nil || !strings.Contains(err.Error(), "no completed backups found") {
t.Fatalf("expected no completed backups error, got %v", err)
}
})
}
func TestDoJSONErrorPaths(t *testing.T) {
ctx := context.Background()
t.Run("encode request", func(t *testing.T) {
client := New("http://example.test")
err := client.doJSON(ctx, http.MethodPost, "http://example.test/path", map[string]any{"bad": func() {}}, nil)
if err == nil || !strings.Contains(err.Error(), "encode request") {
t.Fatalf("expected encode request error, got %v", err)
}
})
t.Run("build request", func(t *testing.T) {
client := New("http://example.test")
err := client.doJSON(ctx, http.MethodGet, "://bad-url", nil, nil)
if err == nil || !strings.Contains(err.Error(), "build request") {
t.Fatalf("expected build request error, got %v", err)
}
})
t.Run("request failed", func(t *testing.T) {
client := New("http://example.test")
client.http = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial failed")
})}
err := client.doJSON(ctx, http.MethodGet, "http://example.test/path", nil, nil)
if err == nil || !strings.Contains(err.Error(), "request failed") {
t.Fatalf("expected request failed error, got %v", err)
}
})
t.Run("read response", func(t *testing.T) {
client := New("http://example.test")
client.http = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
})}
err := client.doJSON(ctx, http.MethodGet, "http://example.test/path", nil, nil)
if err == nil || !strings.Contains(err.Error(), "read response") {
t.Fatalf("expected read response error, got %v", err)
}
})
t.Run("api error", func(t *testing.T) {
client := New("http://example.test")
client.http = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader("proxy unhappy")),
Header: make(http.Header),
}, nil
})}
err := client.doJSON(ctx, http.MethodGet, "http://example.test/path", nil, nil)
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusBadGateway || apiErr.Message != "proxy unhappy" {
t.Fatalf("expected APIError 502/proxy unhappy, got %#v", err)
}
})
t.Run("decode response", func(t *testing.T) {
client := New("http://example.test")
client.http = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{not-json")),
Header: make(http.Header),
}, nil
})}
var out Volume
err := client.doJSON(ctx, http.MethodGet, "http://example.test/path", nil, &out)
if err == nil || !strings.Contains(err.Error(), "decode response") {
t.Fatalf("expected decode response error, got %v", err)
}
})
t.Run("success with empty body", func(t *testing.T) {
client := New("http://example.test")
client.http = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
})}
if err := client.doJSON(ctx, http.MethodDelete, "http://example.test/path", nil, nil); err != nil {
t.Fatalf("expected empty-body success, got %v", err)
}
})
}
func assertBodyContains(t *testing.T, body, want string) {
t.Helper()
if !strings.Contains(body, want) {
t.Fatalf("expected body %q to contain %q", body, want)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReader struct{}
func (errReader) Read([]byte) (int, error) {
return 0, errors.New("boom")
}