package workflow_test import ( "archive/zip" "bytes" "context" "fmt" "net/http" "net/http/httptest" "testing" "workflow" ) // ── helpers ────────────────────────────────────────────────────────────────── // makeZip builds an in-memory zip archive from a map of filename→content. func makeZip(files map[string][]byte) []byte { var buf bytes.Buffer w := zip.NewWriter(&buf) for name, data := range files { f, _ := w.Create(name) f.Write(data) } w.Close() return buf.Bytes() } // v314Registry returns a standard registry allowing .tmp/**, artifacts/**, // ExtComponents/**, Services/**, and Sections/** writes. func v314Registry() workflow.Registry { return workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{}, Files: workflow.FilesRegistry{ Artifacts: []string{ ".tmp/**", "artifacts/**", "ExtComponents/**", "Services/**", "Sections/**", }, }, } } // v314Adapters wraps a FileAdapter in the full Adapters struct. func v314Adapters(fa workflow.FileAdapter) *workflow.Adapters { return &workflow.Adapters{ Service: workflow.NewDefaultServiceAdapter(), Component: workflow.NewDefaultComponentAdapter(), LLM: workflow.NewDefaultLLMAdapter(), File: fa, } } func mustEngineV314(t *testing.T, wf *workflow.Workflow) *workflow.Engine { t.Helper() eng, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } return eng } // mustExecuteV314 starts execution and blocks until the workflow finishes. func mustExecuteV314(t *testing.T, eng *workflow.Engine, adapters *workflow.Adapters) *workflow.ExecutionResult { t.Helper() result, err := eng.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(result.RunEventStream) // wait for goroutine to finish return result } // ── Write_* prepend (v3.14) ─────────────────────────────────────────────── func TestV314_WritePrepend(t *testing.T) { fa := workflow.NewDefaultFileAdapter() fa.SetFile("artifacts/file.txt", []byte("world")) wf := &workflow.Workflow{ Version: "3.14", Name: "Prepend Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Write_Prepend", Target: "artifacts/file.txt", Value: "hello ", Mode: "prepend", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } mustExecuteV314(t, mustEngineV314(t, wf), v314Adapters(fa)) got := string(fa.GetFile("artifacts/file.txt")) if want := "hello world"; got != want { t.Errorf("prepend: got %q, want %q", got, want) } } // ── .tmp/ path isolation (v3.14) ───────────────────────────────────────── func TestV314_TmpPathIsolation(t *testing.T) { fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "Tmp Path Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Write_Tmp", Target: ".tmp/bundle.zip", Value: "data", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(result.RunEventStream) // wait for workflow to finish runID := result.Context.WorkflowID expectedPath := ".tmp/" + runID + "/bundle.zip" if fa.GetFile(expectedPath) == nil { t.Errorf(".tmp/ not isolated: expected file at %s", expectedPath) } // Bare unresolved path must NOT exist if fa.GetFile(".tmp/bundle.zip") != nil { t.Errorf(".tmp/ isolation: bare path should not be written") } } // ── _iterDir injection in parallel loop (v3.14) ────────────────────────── func TestV314_IterDirParallel(t *testing.T) { fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "IterDir Parallel Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{"$items([STRING])"}, Files: workflow.FilesRegistry{Artifacts: []string{".tmp/**"}}, }, Steps: []workflow.Step{ {ID: "Set_Items", Target: "$items", Value: `=["a","b","c"]`, Next: "Loop_Process"}, { ID: "Loop_Process", Source: "=$items", Mode: "parallel", Children: []string{"Write_TmpFile"}, Next: "Stop_Done", }, {ID: "Write_TmpFile", Target: ".tmp/{_iterDir}/item.txt", Value: "=_item", Next: "RETURN"}, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(result.RunEventStream) // wait for all parallel goroutines to finish runID := result.Context.WorkflowID for i, want := range []string{"a", "b", "c"} { iterDir := fmt.Sprintf("Loop_Process_%d", i) path := ".tmp/" + runID + "/" + iterDir + "/item.txt" got := fa.GetFile(path) if got == nil { t.Errorf("missing file for iteration %d at %s", i, path) continue } if string(got) != want { t.Errorf("iteration %d: got %q, want %q", i, string(got), want) } } } // ── _iterDir injection in serial loop (v3.14) ──────────────────────────── func TestV314_IterDirSerial(t *testing.T) { fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "IterDir Serial Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{"$items([STRING])"}, Files: workflow.FilesRegistry{Artifacts: []string{".tmp/**"}}, }, Steps: []workflow.Step{ {ID: "Set_Items", Target: "$items", Value: `=["x","y"]`, Next: "Loop_Serial"}, { ID: "Loop_Serial", Source: "=$items", Mode: "serial", Children: []string{"Write_TmpFile"}, Next: "Stop_Done", }, {ID: "Write_TmpFile", Target: ".tmp/{_iterDir}/item.txt", Value: "=_item", Next: "RETURN"}, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(result.RunEventStream) // wait for workflow to finish runID := result.Context.WorkflowID for i, want := range []string{"x", "y"} { iterDir := fmt.Sprintf("Loop_Serial_%d", i) path := ".tmp/" + runID + "/" + iterDir + "/item.txt" got := fa.GetFile(path) if got == nil { t.Errorf("missing file for serial iteration %d at %s", i, path) continue } if string(got) != want { t.Errorf("serial iteration %d: got %q, want %q", i, string(got), want) } } } // ── Download_* with explicit target (v3.14) ────────────────────────────── func TestV314_DownloadTarget(t *testing.T) { content := []byte("file-content-abc") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(content) })) defer srv.Close() fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "Download Target Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Download_File", Source: fmt.Sprintf("%s/file.cp", srv.URL), Target: "artifacts/downloaded.cp", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } mustExecuteV314(t, mustEngineV314(t, wf), v314Adapters(fa)) got := fa.GetFile("artifacts/downloaded.cp") if !bytes.Equal(got, content) { t.Errorf("download content mismatch: got %q, want %q", got, content) } } // ── Download_* with routeByExt (v3.14) ─────────────────────────────────── func TestV314_DownloadRouteByExt(t *testing.T) { content := []byte("component-data") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(content) })) defer srv.Close() fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "Download RouteByExt Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Download_Cp", Source: fmt.Sprintf("%s/MyWidget.cp", srv.URL), RouteByExt: map[string]string{ ".cp": "ExtComponents/", ".vs": "Services/", }, DefaultDir: "artifacts/misc/", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } mustExecuteV314(t, mustEngineV314(t, wf), v314Adapters(fa)) got := fa.GetFile("ExtComponents/MyWidget.cp") if !bytes.Equal(got, content) { t.Errorf("routeByExt content mismatch: got %q, want %q", got, content) } } // ── Unzip_* with routeByExt (v3.14) ────────────────────────────────────── func TestV314_UnzipRouteByExt(t *testing.T) { zipData := makeZip(map[string][]byte{ "Widget.cp": []byte("cp-content"), "Service.vs": []byte("vs-content"), "Readme.txt": []byte("readme"), }) fa := workflow.NewDefaultFileAdapter() fa.SetFile("artifacts/bundle.zip", zipData) wf := &workflow.Workflow{ Version: "3.14", Name: "Unzip RouteByExt Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Unzip_Bundle", Source: "artifacts/bundle.zip", RouteByExt: map[string]string{ ".cp": "ExtComponents/", ".vs": "Services/", }, DefaultDir: "artifacts/misc/", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } mustExecuteV314(t, mustEngineV314(t, wf), v314Adapters(fa)) cases := []struct{ path, want string }{ {"ExtComponents/Widget.cp", "cp-content"}, {"Services/Service.vs", "vs-content"}, {"artifacts/misc/Readme.txt", "readme"}, } for _, c := range cases { got := string(fa.GetFile(c.path)) if got != c.want { t.Errorf("%s: got %q, want %q", c.path, got, c.want) } } } // ── Unzip_* zip-slip protection (v3.14) ────────────────────────────────── func TestV314_UnzipZipSlipRejected(t *testing.T) { var buf bytes.Buffer zw := zip.NewWriter(&buf) f, _ := zw.Create("../../etc/passwd") f.Write([]byte("root:x:0:0")) zw.Close() fa := workflow.NewDefaultFileAdapter() fa.SetFile("artifacts/evil.zip", buf.Bytes()) wf := &workflow.Workflow{ Version: "3.14", Name: "ZipSlip Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Unzip_Evil", Source: "artifacts/evil.zip", RouteByExt: map[string]string{}, DefaultDir: "artifacts/out/", Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute (start): %v", err) } drainEvents(result.RunEventStream) // wait for workflow to finish if result.Context.Status != workflow.StatusFailed { t.Errorf("expected workflow to fail for zip-slip entry, got status %q", result.Context.Status) } } // ── Unzip_* overwrite=false (v3.14) ────────────────────────────────────── func TestV314_UnzipNoOverwrite(t *testing.T) { zipData := makeZip(map[string][]byte{ "file.cp": []byte("new-content"), }) fa := workflow.NewDefaultFileAdapter() fa.SetFile("artifacts/bundle.zip", zipData) fa.SetFile("ExtComponents/file.cp", []byte("original")) overwriteFalse := false wf := &workflow.Workflow{ Version: "3.14", Name: "Unzip No Overwrite Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Unzip_Bundle", Source: "artifacts/bundle.zip", RouteByExt: map[string]string{".cp": "ExtComponents/"}, Overwrite: &overwriteFalse, Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute (start): %v", err) } drainEvents(result.RunEventStream) // wait for workflow to finish if result.Context.Status != workflow.StatusFailed { t.Errorf("expected workflow to fail when overwrite=false on existing file, got status %q", result.Context.Status) } if got := string(fa.GetFile("ExtComponents/file.cp")); got != "original" { t.Errorf("original file overwritten despite overwrite=false, got %q", got) } } // ── Download_* → Unzip_* full pipeline (v3.14) ─────────────────────────── func TestV314_DownloadThenUnzip(t *testing.T) { zipData := makeZip(map[string][]byte{ "Alpha.cp": []byte("alpha"), "Beta.vs": []byte("beta"), }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(zipData) })) defer srv.Close() fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "Download+Unzip Pipeline Test", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Download_Bundle", Source: fmt.Sprintf("%s/bundle.zip", srv.URL), Target: ".tmp/bundle.zip", Next: "Unzip_Bundle", }, { ID: "Unzip_Bundle", Source: ".tmp/bundle.zip", RouteByExt: map[string]string{ ".cp": "ExtComponents/", ".vs": "Services/", }, Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) result, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(result.RunEventStream) // wait for workflow to finish runID := result.Context.WorkflowID tmpZip := ".tmp/" + runID + "/bundle.zip" if fa.GetFile(tmpZip) == nil { t.Errorf("expected zip at isolated path %s", tmpZip) } if got := fa.GetFile("ExtComponents/Alpha.cp"); !bytes.Equal(got, []byte("alpha")) { t.Errorf("Alpha.cp: got %q, want %q", got, "alpha") } if got := fa.GetFile("Services/Beta.vs"); !bytes.Equal(got, []byte("beta")) { t.Errorf("Beta.vs: got %q, want %q", got, "beta") } } // ── Version 3.14 accepted by validator ─────────────────────────────────── func TestV314_VersionAccepted(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Version Test", Registry: v314Registry(), Steps: []workflow.Step{{ID: "Stop_Done"}}, } if err := wf.Validate(); err != nil { t.Errorf("version 3.14 should be accepted: %v", err) } } // ── Validation: Download_* missing source ──────────────────────────────── func TestV314_Validate_DownloadMissingSource(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Validate Download No Source", Registry: v314Registry(), Steps: []workflow.Step{ {ID: "Download_File", Target: "artifacts/file.txt", Next: "Stop_Done"}, // no Source {ID: "Stop_Done"}, }, } if err := wf.Validate(); err == nil { t.Error("expected validation error for Download_* without source, got nil") } } // ── Validation: Download_* missing target and routeByExt ───────────────── func TestV314_Validate_DownloadMissingRoute(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Validate Download No Route", Registry: v314Registry(), Steps: []workflow.Step{ // Has source but neither target nor routeByExt/defaultDir {ID: "Download_File", Source: "http://example.com/file.zip", Next: "Stop_Done"}, {ID: "Stop_Done"}, }, } if err := wf.Validate(); err == nil { t.Error("expected validation error for Download_* without target/routeByExt, got nil") } } // ── Validation: Download_* both target and routeByExt (mutually exclusive) ─ func TestV314_Validate_DownloadBothTargetAndRoute(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Validate Download Both Target and Route", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Download_File", Source: "http://example.com/file.zip", Target: "artifacts/file.zip", RouteByExt: map[string]string{".zip": "artifacts/"}, Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } if err := wf.Validate(); err == nil { t.Error("expected validation error when both target and routeByExt are set, got nil") } } // ── Validation: Unzip_* missing source ─────────────────────────────────── func TestV314_Validate_UnzipMissingSource(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Validate Unzip No Source", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Unzip_Bundle", RouteByExt: map[string]string{".cp": "ExtComponents/"}, Next: "Stop_Done", }, // no Source {ID: "Stop_Done"}, }, } if err := wf.Validate(); err == nil { t.Error("expected validation error for Unzip_* without source, got nil") } } // ── Validation: Unzip_* missing routeByExt ─────────────────────────────── func TestV314_Validate_UnzipMissingRouteByExt(t *testing.T) { wf := &workflow.Workflow{ Version: "3.14", Name: "Validate Unzip No RouteByExt", Registry: v314Registry(), Steps: []workflow.Step{ { ID: "Unzip_Bundle", Source: "artifacts/bundle.zip", // RouteByExt not set (nil) Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } if err := wf.Validate(); err == nil { t.Error("expected validation error for Unzip_* without routeByExt, got nil") } } // ── Unzip_* out mapping captures extraction summary ────────────────────── func TestV314_UnzipOutMapping(t *testing.T) { zipData := makeZip(map[string][]byte{ "A.cp": []byte("a-content"), "B.cp": []byte("b-content"), }) fa := workflow.NewDefaultFileAdapter() fa.SetFile("artifacts/pkg.zip", zipData) wf := &workflow.Workflow{ Version: "3.14", Name: "Unzip Out Mapping Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{"$unzipCount(NUMBER)", "$unzipFiles([STRING])"}, Files: workflow.FilesRegistry{ Artifacts: []string{"artifacts/**", "ExtComponents/**"}, }, }, Steps: []workflow.Step{ { ID: "Unzip_Bundle", Source: "artifacts/pkg.zip", RouteByExt: map[string]string{ ".cp": "ExtComponents/", }, Out: workflow.StepOutput{ "$unzipCount": "=_result.count", "$unzipFiles": "=_result.files", }, Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) res, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(res.RunEventStream) // Verify count was mapped (extractedPaths has 2 entries) count := res.Context.Variables["$unzipCount"] if count == nil { t.Error("$unzipCount should be set after out mapping") } // Verify files list was mapped (non-nil, and the correct count) files := res.Context.Variables["$unzipFiles"] if files == nil { t.Error("$unzipFiles should be set after out mapping") } } // ── Download_* out mapping captures written path ────────────────────────── func TestV314_DownloadOutMapping(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("download-content")) })) defer srv.Close() fa := workflow.NewDefaultFileAdapter() wf := &workflow.Workflow{ Version: "3.14", Name: "Download Out Mapping Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{"$downloadedPath(STRING)"}, Files: workflow.FilesRegistry{ Artifacts: []string{"artifacts/**"}, }, }, Steps: []workflow.Step{ { ID: "Download_File", Source: fmt.Sprintf("%s/widget.cp", srv.URL), Target: "artifacts/widget.cp", Out: workflow.StepOutput{ "$downloadedPath": "=_result.path", }, Next: "Stop_Done", }, {ID: "Stop_Done"}, }, } eng := mustEngineV314(t, wf) res, err := eng.Execute(context.Background(), map[string]interface{}{}, v314Adapters(fa)) if err != nil { t.Fatalf("Execute: %v", err) } drainEvents(res.RunEventStream) got, ok := res.Context.Variables["$downloadedPath"].(string) if !ok || got == "" { t.Errorf("$downloadedPath: got %v (%T), want non-empty string", res.Context.Variables["$downloadedPath"], res.Context.Variables["$downloadedPath"]) } if got != "artifacts/widget.cp" { t.Errorf("$downloadedPath: got %q, want %q", got, "artifacts/widget.cp") } }