diff --git a/console/internal/api/function_clone_test.go b/console/internal/api/function_clone_test.go new file mode 100644 index 0000000..6409abe --- /dev/null +++ b/console/internal/api/function_clone_test.go @@ -0,0 +1,629 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "fission-console/internal/fission" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и +// опциональным HTTP-сервером, имитирующим storagesvc. +func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) { + t.Helper() + scheme := k8sruntime.NewScheme() + listKinds := map[schema.GroupVersionResource]string{ + fission.FunctionGVR: "FunctionList", + fission.PackageGVR: "PackageList", + fission.HTTPTrigGVR: "HTTPTriggerList", + } + runtimeObjs := make([]k8sruntime.Object, len(objs)) + for i, o := range objs { + runtimeObjs[i] = o + } + dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...) + + var storagesvc *httptest.Server + httpClient := &http.Client{} + if storagesvcHandler != nil { + storagesvc = httptest.NewServer(storagesvcHandler) + httpClient = storagesvc.Client() + } + + s := &Server{ + dyn: dynClient, + ns: "fission-test", + http: httpClient, + } + return s, storagesvc +} + +// cloneRequest выполняет POST /functions/:srcName/clone с телом body. +func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, + "/console/api/functions/"+srcName+"/clone", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Auth-Token", "test-token") + rec := httptest.NewRecorder() + s.handleCloneFunction(rec, req, srcName) + return rec +} + +// makeZip возвращает минимальный zip-архив с одним файлом filename/content. +func makeZip(t *testing.T, filename, content string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create(filename) + if err != nil { + t.Fatalf("zip.Create: %v", err) + } + if _, err := io.WriteString(f, content); err != nil { + t.Fatalf("zip.Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("zip.Close: %v", err) + } + return buf.Bytes() +} + +// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes). +func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{"name": name, "namespace": ns}, + "spec": map[string]any{ + "deployment": map[string]any{ + "type": "literal", + "literal": base64.StdEncoding.EncodeToString(data), + }, + "environment": map[string]any{"name": fnName, "namespace": ns}, + "source": map[string]any{}, + }, + }} +} + +// packageWithURL создаёт Package CRD с type:url. +func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{"name": pkgName, "namespace": ns}, + "spec": map[string]any{ + "deployment": map[string]any{ + "type": "url", + "url": archiveURL, + }, + "environment": map[string]any{"name": envName, "namespace": ns}, + "source": map[string]any{}, + }, + }} +} + +// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName. +func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": fnName, + "namespace": ns, + "annotations": map[string]any{ + "fission-console/language": envName, + "fission-console/source-type": "code", + "fission-console/created-at": "2026-05-09T00:00:00Z", + "fission-console/updated-at": "2026-05-09T00:00:00Z", + }, + }, + "spec": map[string]any{ + "environment": map[string]any{"name": envName, "namespace": ns}, + "functionTimeout": int64(120), + "package": map[string]any{ + "packageref": map[string]any{"name": pkgName, "namespace": ns}, + "functionName": entrypoint, + }, + "InvokeStrategy": map[string]any{ + "ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"}, + "StrategyType": "execution", + }, + }, + }} +} + +// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName. +func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "HTTPTrigger", + "metadata": map[string]any{"name": trigName, "namespace": ns}, + "spec": map[string]any{ + "relativeurl": route, + "methods": methods, + "createingress": true, + "functionref": map[string]any{"type": "name", "name": fnName}, + }, + }} +} + +// ─── Тесты валидации имени ─────────────────────────────────────────────────── + +func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "src", `{"new_name":""}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } + if !strings.Contains(rec.Body.String(), "new_name is required") { + t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body) + } +} + +func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } +} + +func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } +} + +func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } +} + +func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + longName := strings.Repeat("a", 58) + rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } +} + +func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) { + // 57 символов — допустимо; но функция src не существует → 404 + s, _ := newCloneTestServer(t, nil) + name57 := strings.Repeat("a", 57) + rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57)) + // Ожидаем не 400 (прошло валидацию), а 404 (src не найден) + if rec.Code == http.StatusBadRequest { + t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body) + } +} + +func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) { + // Имя вида "a-b-c" — допустимо + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`) + if rec.Code == http.StatusBadRequest { + t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body) + } +} + +// ─── Тест: источник не существует ──────────────────────────────────────────── + +func TestHandleCloneFunction_SourceNotFound(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body) + } + if !strings.Contains(rec.Body.String(), "ghost") { + t.Fatalf("body should mention 'ghost', got: %s", rec.Body) + } +} + +// ─── Тест: успешный клон с type:literal ────────────────────────────────────── + +func TestHandleCloneFunction_Success_Literal(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n") + + pkg := packageWithLiteral(ns, "src-pkg", "python", zipData) + fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main") + trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"}) + + // storagesvc принимает загрузку и возвращает URL + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + // Имитируем загрузку — возвращаем ID архива + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`) + return + } + http.NotFound(w, r) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn, trig) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["name"] != "src-copy" { + t.Fatalf("response.name = %v, want 'src-copy'", resp["name"]) + } + if resp["cloned_from"] != "src" { + t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"]) + } + if resp["package"] == "" || resp["package"] == nil { + t.Fatalf("response.package is empty") + } + + ctx := context.Background() + + // Проверяем что Function создана + cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{}) + if err != nil { + t.Fatalf("clone Function not found: %v", err) + } + + // Аннотация cloned-from + ann := cloneFn.GetAnnotations() + if ann["fission-console/cloned-from"] != "src" { + t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"]) + } + // Аннотации language и source-type скопированы + if ann["fission-console/language"] != "python" { + t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"]) + } + if ann["fission-console/source-type"] != "code" { + t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"]) + } + + // Entrypoint скопирован + ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName") + if ep != "main.main" { + t.Fatalf("entrypoint = %q, want 'main.main'", ep) + } + + // Timeout скопирован + timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout") + if timeout != 120 { + t.Fatalf("timeout = %d, want 120", timeout) + } + + // Проверяем что HTTPTrigger создан + trigName := "src-copy-route" + cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("clone HTTPTrigger not found: %v", err) + } + + // Методы скопированы из оригинального триггера + methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods") + if len(methods) != 2 { + t.Fatalf("methods = %v, want [GET POST]", methods) + } + + // Маршрут сгенерирован автоматически (содержит new_name) + route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl") + if !strings.HasSuffix(route, "/src-copy") { + t.Fatalf("route = %q, should end with '/src-copy'", route) + } +} + +// ─── Тест: маршрут задан явно ──────────────────────────────────────────────── + +func TestHandleCloneFunction_CustomRoute(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n") + + pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData) + fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main") + + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"fission/arc-456"}`) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body) + } + + ctx := context.Background() + trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{}) + if err != nil { + t.Fatalf("HTTPTrigger not found: %v", err) + } + route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl") + if route != "/custom/path" { + t.Fatalf("route = %q, want '/custom/path'", route) + } +} + +// ─── Тест: маршрут без слеша — добавляется автоматически ───────────────────── + +func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n") + + pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData) + fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main") + + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"fission/arc-789"}`) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body) + } + + ctx := context.Background() + trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{}) + if err != nil { + t.Fatalf("HTTPTrigger not found: %v", err) + } + route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl") + if !strings.HasPrefix(route, "/") { + t.Fatalf("route = %q, should start with '/'", route) + } +} + +// ─── Тест: дублирующее имя → 409 ───────────────────────────────────────────── + +func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'a'\n") + + pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData) + fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main") + // Уже существующая функция с именем "dup-copy" + existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main") + + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"fission/arc-dup"}`) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body) + } + if !strings.Contains(rec.Body.String(), "dup-copy") { + t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body) + } +} + +// ─── Тест: откат при ошибке создания Function ──────────────────────────────── + +func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n") + + pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData) + fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main") + // Уже существующая функция с именем клона + existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main") + + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"fission/arc-rb"}`) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body) + } + + // После rollback Package с именем "rb-clone-*" не должен остаться + ctx := context.Background() + pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("list packages: %v", err) + } + for _, p := range pkgs.Items { + if strings.HasPrefix(p.GetName(), "rb-clone-") { + t.Fatalf("rollback failed: package %q still exists", p.GetName()) + } + } +} + +// ─── Тест: downloadPackageBytes — type:literal ──────────────────────────────── + +func TestDownloadPackageBytes_Literal(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + + original := []byte("hello from literal") + pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original) + + got, err := s.downloadPackageBytes(context.Background(), pkg) + if err != nil { + t.Fatalf("downloadPackageBytes error: %v", err) + } + if !bytes.Equal(got, original) { + t.Fatalf("got %q, want %q", got, original) + } +} + +// ─── Тест: downloadPackageBytes — type:url ──────────────────────────────────── + +func TestDownloadPackageBytes_URL(t *testing.T) { + const content = "archive-content-from-storagesvc" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, content) + })) + defer srv.Close() + + pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc") + s := &Server{http: srv.Client()} + + got, err := s.downloadPackageBytes(context.Background(), pkg) + if err != nil { + t.Fatalf("downloadPackageBytes error: %v", err) + } + if string(got) != content { + t.Fatalf("got %q, want %q", got, content) + } +} + +// ─── Тест: downloadPackageBytes — пустой Package → ошибка ──────────────────── + +func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + emptyPkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{"name": "empty", "namespace": "fission-test"}, + "spec": map[string]any{}, + }} + _, err := s.downloadPackageBytes(context.Background(), emptyPkg) + if err == nil { + t.Fatal("expected error for empty package, got nil") + } +} + +// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ──────────────────────── + +func TestDownloadFromStoragesvc_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + s := &Server{http: srv.Client()} + _, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone") + if err == nil { + t.Fatal("expected error for 404 response, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Fatalf("error should mention 404, got: %v", err) + } +} + +// ─── Тест: getTriggerMethods — триггер существует ──────────────────────────── + +func TestGetTriggerMethods_Found(t *testing.T) { + const ns = "fission-test" + trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"}) + s, _ := newCloneTestServer(t, nil, trig) + + methods := s.getTriggerMethods(context.Background(), ns, "my-fn") + if len(methods) != 2 { + t.Fatalf("methods = %v, want 2 elements", methods) + } +} + +// ─── Тест: getTriggerMethods — триггера нет → nil ──────────────────────────── + +func TestGetTriggerMethods_NotFound(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn") + if methods != nil { + t.Fatalf("expected nil, got %v", methods) + } +} + +// ─── Тест: невалидный JSON body → 400 ──────────────────────────────────────── + +func TestHandleCloneFunction_InvalidJSON(t *testing.T) { + s, _ := newCloneTestServer(t, nil) + req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone", + strings.NewReader(`not-json`)) + rec := httptest.NewRecorder() + s.handleCloneFunction(rec, req, "src") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body) + } +} + +// ─── Тест: успешный клон — Package содержит ссылку на Function ─────────────── + +func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) { + const ns = "fission-test" + zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n") + pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData) + fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main") + + storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"fission/arc-ref"}`) + })) + defer storagesvc.Close() + + s, _ := newCloneTestServer(t, nil, pkg, fn) + s.storagesvcURL = storagesvc.URL + s.http = storagesvc.Client() + + rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body) + } + + ctx := context.Background() + cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{}) + if err != nil { + t.Fatalf("clone function not found: %v", err) + } + + pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name") + if !strings.HasPrefix(pkgRef, "ref-clone-") { + t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef) + } + + // Package с этим именем должен существовать + _, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{}) + if err != nil { + t.Fatalf("clone Package %q not found: %v", pkgRef, err) + } +}