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