package api import ( "archive/zip" "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "fission-console/internal/billing" "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, billing: billing.NoopStore{}, } 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) } }