From 38c35259d937d42410a09880a48be768e2f45b4b Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Fri, 5 Dec 2025 10:12:29 +0300 Subject: [PATCH 1/2] feat: enhance bundle upload with validation and auto-activation - Add comprehensive pre-upload validation matching server-side logic - Add --activate flag for automatic version activation - Add --verbose flag for detailed bundle information - Add validation package with 13 comprehensive test cases - Improve error messages with color-coded output - Validate bundle structure, files, JSON, and renderer references Closes #36 --- synkronus-cli/README.md | 6 + synkronus-cli/internal/cmd/appbundle.go | 114 ++++++- synkronus-cli/pkg/validation/bundle.go | 323 ++++++++++++++++++++ synkronus-cli/pkg/validation/bundle_test.go | 238 +++++++++++++++ 4 files changed, 676 insertions(+), 5 deletions(-) create mode 100644 synkronus-cli/pkg/validation/bundle.go create mode 100644 synkronus-cli/pkg/validation/bundle_test.go diff --git a/synkronus-cli/README.md b/synkronus-cli/README.md index 18aa003b9..9a75ecbed 100644 --- a/synkronus-cli/README.md +++ b/synkronus-cli/README.md @@ -123,6 +123,12 @@ synk app-bundle download index.html # Upload a new app bundle (admin only) synk app-bundle upload bundle.zip +# Upload with auto-activation and verbose output +synk app-bundle upload bundle.zip --activate --verbose + +# Upload with validation skipped (not recommended) +synk app-bundle upload bundle.zip --skip-validation + # Switch to a specific app bundle version (admin only) synk app-bundle switch 20250507-123456 ``` diff --git a/synkronus-cli/internal/cmd/appbundle.go b/synkronus-cli/internal/cmd/appbundle.go index 187f9dbbc..f1c505056 100644 --- a/synkronus-cli/internal/cmd/appbundle.go +++ b/synkronus-cli/internal/cmd/appbundle.go @@ -7,6 +7,8 @@ import ( "path/filepath" "github.com/OpenDataEnsemble/ode/synkronus-cli/pkg/client" + "github.com/OpenDataEnsemble/ode/synkronus-cli/pkg/validation" + "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -216,8 +218,13 @@ Use the --preview flag to ensure you get the preview version of the app bundle.` uploadCmd := &cobra.Command{ Use: "upload [file]", Short: "Upload a new app bundle", - Long: `Upload a new app bundle ZIP file to the Synkronus API (admin only).`, - Args: cobra.ExactArgs(1), + Long: `Upload a new app bundle ZIP file to the Synkronus API (admin only). + +The bundle will be validated before upload to ensure it has the correct structure. +Use --skip-validation to bypass validation (not recommended). + +After upload, use --activate to automatically activate the new version.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { bundlePath := args[0] @@ -227,20 +234,117 @@ Use the --preview flag to ensure you get the preview version of the app bundle.` return fmt.Errorf("file not found: %s: %w", bundlePath, err) } + // Get flags + skipValidation, _ := cmd.Flags().GetBool("skip-validation") + activate, _ := cmd.Flags().GetBool("activate") + verbose, _ := cmd.Flags().GetBool("verbose") + + // Validate bundle structure (unless skipped) + if !skipValidation { + color.Cyan("Validating bundle structure...") + if err := validation.ValidateBundle(bundlePath); err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("bundle validation failed: %w", err) + } + color.Green("✓ Bundle structure is valid") + } else { + color.Yellow("⚠ Skipping validation (not recommended)") + } + + // Show bundle info + if verbose { + info, err := validation.GetBundleInfo(bundlePath) + if err == nil { + fmt.Println() + color.Cyan("Bundle Information:") + fmt.Printf(" Size: %d bytes\n", info["size"]) + fmt.Printf(" Files: %d\n", info["file_count"]) + fmt.Printf(" Forms: %d\n", info["form_count"]) + fmt.Printf(" Renderers: %d\n", info["renderer_count"]) + fmt.Println() + } + } + + // Upload bundle + color.Cyan("Uploading bundle...") c := client.NewClient() response, err := c.UploadAppBundle(bundlePath) if err != nil { cmd.SilenceUsage = true - // Add context to the error + // Try to parse error message for better output return fmt.Errorf("failed to upload app bundle: %w", err) } - fmt.Println("App bundle uploaded successfully!") - fmt.Printf("Message: %s\n", response["message"]) + color.Green("✓ App bundle uploaded successfully!") + + // Extract version from response + version, ok := response["version"].(string) + if !ok { + // Try to get from manifest + if manifest, ok := response["manifest"].(map[string]interface{}); ok { + version, _ = manifest["version"].(string) + } + } + + if version != "" { + fmt.Printf("Version: %s\n", version) + } + + // Show manifest if verbose + if verbose { + if manifest, ok := response["manifest"].(map[string]interface{}); ok { + fmt.Println() + color.Cyan("Manifest:") + if v, ok := manifest["version"].(string); ok { + fmt.Printf(" Version: %s\n", v) + } + if h, ok := manifest["hash"].(string); ok { + fmt.Printf(" Hash: %s\n", h) + } + if files, ok := manifest["files"].([]interface{}); ok { + fmt.Printf(" Files: %d\n", len(files)) + if len(files) > 0 && len(files) <= 10 { + fmt.Println(" File list:") + for _, file := range files { + if fileMap, ok := file.(map[string]interface{}); ok { + path, _ := fileMap["path"].(string) + size, _ := fileMap["size"].(float64) + fmt.Printf(" - %s (%d bytes)\n", path, int(size)) + } + } + } + } + } + } + + // Auto-activate if requested + if activate && version != "" { + fmt.Println() + color.Cyan("Activating version %s...", version) + switchResponse, err := c.SwitchAppBundleVersion(version) + if err != nil { + color.Yellow("⚠ Warning: Failed to activate version automatically: %v", err) + color.Yellow(" You can activate it manually with: synk app-bundle switch %s", version) + } else { + color.Green("✓ Version %s activated successfully!", version) + } + if verbose && switchResponse != nil { + if msg, ok := switchResponse["message"].(string); ok { + fmt.Printf(" %s\n", msg) + } + } + } else if version != "" { + fmt.Println() + color.Cyan("Tip: Activate this version with:") + fmt.Printf(" synk app-bundle switch %s\n", version) + } return nil }, } + uploadCmd.Flags().Bool("skip-validation", false, "Skip bundle validation before upload (not recommended)") + uploadCmd.Flags().BoolP("activate", "a", false, "Automatically activate the uploaded version") + uploadCmd.Flags().BoolP("verbose", "v", false, "Show detailed information about the bundle and manifest") appBundleCmd.AddCommand(uploadCmd) // Changes command diff --git a/synkronus-cli/pkg/validation/bundle.go b/synkronus-cli/pkg/validation/bundle.go new file mode 100644 index 000000000..946b23ec0 --- /dev/null +++ b/synkronus-cli/pkg/validation/bundle.go @@ -0,0 +1,323 @@ +package validation + +import ( + "archive/zip" + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +var ( + ErrInvalidStructure = errors.New("invalid app bundle structure") + ErrMissingAppIndex = errors.New("missing app/index.html") + ErrInvalidFormStructure = errors.New("invalid form structure") + ErrInvalidRendererStructure = errors.New("invalid renderer structure") + ErrMissingRendererReference = errors.New("missing renderer reference") + ErrInvalidJSON = errors.New("invalid JSON") +) + +// ValidateBundle validates the structure and content of an app bundle ZIP file +func ValidateBundle(bundlePath string) error { + // Open the ZIP file + zipFile, err := zip.OpenReader(bundlePath) + if err != nil { + return fmt.Errorf("failed to open bundle: %w", err) + } + defer zipFile.Close() + + // Track required top-level directories + hasAppDir := false + topDirs := make(map[string]bool) + formDirs := make(map[string]struct{}) + + // First pass: validate top-level structure and collect form directories + for _, file := range zipFile.File { + // Get the top-level directory + parts := strings.SplitN(file.Name, "/", 2) + if len(parts) == 0 { + continue + } + + topDir := parts[0] + if topDir == "app" || topDir == "forms" || topDir == "renderers" { + topDirs[topDir] = true + } else if topDir != "" { + return fmt.Errorf("%w: unexpected top-level directory '%s'", ErrInvalidStructure, topDir) + } + + // Check for app/index.html + if file.Name == "app/index.html" { + hasAppDir = true + } + + // Track form directories + if strings.HasPrefix(file.Name, "forms/") && !strings.HasSuffix(file.Name, "/") { + formParts := strings.Split(file.Name, "/") + if len(formParts) >= 2 { + formDirs[formParts[1]] = struct{}{} + } + } + } + + // Ensure we have the required app directory with index.html + if !hasAppDir { + return ErrMissingAppIndex + } + + // Second pass: validate forms and renderers structure + hasFormSchema := make(map[string]bool) + hasFormUI := make(map[string]bool) + + for _, file := range zipFile.File { + // Validate form structure + if strings.HasPrefix(file.Name, "forms/") { + if err := validateFormFile(file); err != nil { + return err + } + + // Track which forms have schema.json and ui.json + parts := strings.Split(file.Name, "/") + if len(parts) >= 3 { + formName := parts[1] + switch parts[2] { + case "schema.json": + hasFormSchema[formName] = true + // Validate JSON + if err := validateJSONFile(file); err != nil { + return fmt.Errorf("invalid JSON in form schema %s: %w", file.Name, err) + } + case "ui.json": + hasFormUI[formName] = true + // Validate JSON + if err := validateJSONFile(file); err != nil { + return fmt.Errorf("invalid JSON in form UI %s: %w", file.Name, err) + } + } + } + } else if strings.HasPrefix(file.Name, "renderers/") { + if err := validateRendererFile(file); err != nil { + return err + } + } + } + + // Verify each form directory has both schema.json and ui.json (server requires both) + for formDir := range formDirs { + if !hasFormSchema[formDir] || !hasFormUI[formDir] { + return fmt.Errorf("%w: form '%s' is missing required files (schema.json or ui.json)", ErrInvalidFormStructure, formDir) + } + } + + // Third pass: validate form references to renderers + return validateFormRendererReferences(&zipFile.Reader) +} + +// validateFormFile validates a single form file +func validateFormFile(file *zip.File) error { + // Skip directories + if file.FileInfo().IsDir() { + return nil + } + + // Expected path format: forms/{formName}/schema.json or forms/{formName}/ui.json + parts := strings.Split(file.Name, "/") + if len(parts) != 3 || (parts[2] != "schema.json" && parts[2] != "ui.json") { + return fmt.Errorf("%w: invalid form file path: %s", ErrInvalidFormStructure, file.Name) + } + + return nil +} + +// validateRendererFile validates a single renderer file +func validateRendererFile(file *zip.File) error { + // Skip directories + if file.FileInfo().IsDir() { + return nil + } + + // Expected path format: renderers/{rendererName}/renderer.jsx + parts := strings.Split(file.Name, "/") + if len(parts) != 3 || parts[2] != "renderer.jsx" { + return fmt.Errorf("%w: invalid renderer file path: %s (expected renderers/{name}/renderer.jsx)", ErrInvalidRendererStructure, file.Name) + } + + return nil +} + +// validateFormRendererReferences validates that all renderer references in forms exist +func validateFormRendererReferences(zipReader *zip.Reader) error { + // Build a set of available renderers + availableRenderers := make(map[string]bool) + + // First, collect all available renderers + for _, file := range zipReader.File { + if strings.HasPrefix(file.Name, "renderers/") && strings.HasSuffix(file.Name, "/renderer.jsx") { + parts := strings.Split(file.Name, "/") + if len(parts) == 3 { + availableRenderers[parts[1]] = true + } + } + } + + // Then check all form schemas for renderer references + for _, file := range zipReader.File { + if strings.HasSuffix(file.Name, "schema.json") { + // Open the file + f, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open form schema: %w", err) + } + + // Parse the schema + var schema map[string]interface{} + err = json.NewDecoder(f).Decode(&schema) + f.Close() // Close the file immediately after reading + if err != nil { + return fmt.Errorf("failed to parse form schema: %w", err) + } + + // Check for renderer references in the schema + if err := checkRendererReferences(schema, availableRenderers); err != nil { + return fmt.Errorf("%w: %v", ErrMissingRendererReference, err) + } + } + } + + return nil +} + +// builtInRenderers is a slice of standard JSONForms renderers that don't need to be defined in the bundle +var builtInRenderers = []string{ + // Basic controls + "text", "number", "integer", "boolean", "date", "time", "datetime", "range", + // Selection controls + "select", "combo", "radio", "checkbox", "toggle", + // Layout + "group", "categorization", "category", + // Specialized controls + "multiselect", "textarea", "slider", "rating", + // Built-in aliases (test support) + "builtin-text", + // formulus controls + "image", "signature", "audio", "video", "file", "qrcode", +} + +// isBuiltInRenderer checks if a renderer type is a built-in renderer +func isBuiltInRenderer(rendererType string) bool { + for _, builtIn := range builtInRenderers { + if builtIn == rendererType { + return true + } + } + return false +} + +// checkRendererReferences recursively checks for renderer references in the schema +func checkRendererReferences(data interface{}, availableRenderers map[string]bool) error { + switch v := data.(type) { + case map[string]interface{}: + // Check for renderer type (both x-renderer and rendererType formats) + if rendererType, ok := v["x-renderer"].(string); ok { + if !availableRenderers[rendererType] && !isBuiltInRenderer(rendererType) { + return fmt.Errorf("references non-existent renderer '%s' (x-renderer)", rendererType) + } + } + if rendererType, ok := v["rendererType"].(string); ok { + if !availableRenderers[rendererType] && !isBuiltInRenderer(rendererType) { + return fmt.Errorf("references non-existent renderer '%s' (rendererType)", rendererType) + } + } + if rendererType, ok := v["x-question-type"].(string); ok { + if !availableRenderers[rendererType] && !isBuiltInRenderer(rendererType) { + return fmt.Errorf("references non-existent renderer '%s' (x-question-type)", rendererType) + } + } + + // Recursively check nested objects + for _, value := range v { + if err := checkRendererReferences(value, availableRenderers); err != nil { + return err + } + } + + case []interface{}: + // Recursively check array elements + for _, item := range v { + if err := checkRendererReferences(item, availableRenderers); err != nil { + return err + } + } + } + + return nil +} + +// validateJSONFile validates that a file contains valid JSON +func validateJSONFile(file *zip.File) error { + rc, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer rc.Close() + + var jsonData interface{} + decoder := json.NewDecoder(rc) + if err := decoder.Decode(&jsonData); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidJSON, err) + } + + return nil +} + +// GetBundleInfo returns basic information about the bundle +func GetBundleInfo(bundlePath string) (map[string]interface{}, error) { + info := make(map[string]interface{}) + + // Get file size + fileInfo, err := os.Stat(bundlePath) + if err != nil { + return nil, err + } + info["size"] = fileInfo.Size() + + // Open the ZIP file + zipFile, err := zip.OpenReader(bundlePath) + if err != nil { + return nil, fmt.Errorf("failed to open bundle: %w", err) + } + defer zipFile.Close() + + // Count files and directories + fileCount := 0 + formCount := 0 + rendererCount := 0 + hasAppIndex := false + + for _, file := range zipFile.File { + if !strings.HasSuffix(file.Name, "/") { + fileCount++ + } + + if file.Name == "app/index.html" { + hasAppIndex = true + } + + if strings.HasPrefix(file.Name, "forms/") && strings.HasSuffix(file.Name, "/schema.json") { + formCount++ + } + + if strings.HasPrefix(file.Name, "renderers/") && strings.HasSuffix(file.Name, ".jsx") { + rendererCount++ + } + } + + info["file_count"] = fileCount + info["form_count"] = formCount + info["renderer_count"] = rendererCount + info["has_app_index"] = hasAppIndex + + return info, nil +} + diff --git a/synkronus-cli/pkg/validation/bundle_test.go b/synkronus-cli/pkg/validation/bundle_test.go new file mode 100644 index 000000000..70c782411 --- /dev/null +++ b/synkronus-cli/pkg/validation/bundle_test.go @@ -0,0 +1,238 @@ +package validation + +import ( + "archive/zip" + "bytes" + "os" + "strings" + "testing" +) + +func createTestBundle(t *testing.T, files map[string]string) string { + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + + for name, content := range files { + f, err := w.Create(name) + if err != nil { + t.Fatalf("failed to create zip entry %s: %v", name, err) + } + _, err = f.Write([]byte(content)) + if err != nil { + t.Fatalf("failed to write zip entry content %s: %v", name, err) + } + } + + err := w.Close() + if err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + + // Write to temp file + tmpFile, err := os.CreateTemp("", "test-bundle-*.zip") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer tmpFile.Close() + + _, err = tmpFile.Write(buf.Bytes()) + if err != nil { + t.Fatalf("failed to write zip data: %v", err) + } + + return tmpFile.Name() +} + +func TestValidateBundle(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantErr bool + errMsg string + }{ + { + name: "valid bundle", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object", "properties": {"name": {"type": "string", "x-question-type": "text"}}}`, + "forms/user/ui.json": "{}", + "renderers/button/renderer.jsx": "export default function Button() {}", + }, + wantErr: false, + }, + { + name: "missing app/index.html", + files: map[string]string{ + "forms/user/schema.json": `{"type": "object"}`, + "forms/user/ui.json": "{}", + }, + wantErr: true, + errMsg: "missing app/index.html", + }, + { + name: "invalid top-level directory", + files: map[string]string{ + "app/index.html": "", + "invalid-dir/file.txt": "should not be here", + }, + wantErr: true, + errMsg: "unexpected top-level directory", + }, + { + name: "missing schema.json", + files: map[string]string{ + "app/index.html": "", + "forms/user/ui.json": "{}", + }, + wantErr: true, + errMsg: "missing required files", + }, + { + name: "missing ui.json", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object"}`, + }, + wantErr: true, + errMsg: "missing required files", + }, + { + name: "invalid form file path", + files: map[string]string{ + "app/index.html": "", + "forms/user/invalid-file.txt": "should not be here", + "forms/user/schema.json": `{"type": "object"}`, + "forms/user/ui.json": "{}", + }, + wantErr: true, + errMsg: "invalid form file path", + }, + { + name: "invalid renderer structure - wrong extension", + files: map[string]string{ + "app/index.html": "", + "renderers/button/renderer.js": "should be .jsx", + }, + wantErr: true, + errMsg: "invalid renderer file path", + }, + { + name: "invalid renderer structure - wrong filename", + files: map[string]string{ + "app/index.html": "", + "renderers/button/component.jsx": "should be renderer.jsx", + }, + wantErr: true, + errMsg: "invalid renderer file path", + }, + { + name: "missing renderer reference", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object", "properties": {"field": {"type": "string", "x-question-type": "custom-renderer"}}}`, + "forms/user/ui.json": "{}", + }, + wantErr: true, + errMsg: "references non-existent renderer", + }, + { + name: "valid renderer reference", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object", "properties": {"field": {"type": "string", "x-question-type": "custom-renderer"}}}`, + "forms/user/ui.json": "{}", + "renderers/custom-renderer/renderer.jsx": "export default function CustomRenderer() {}", + }, + wantErr: false, + }, + { + name: "built-in renderer reference", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object", "properties": {"field": {"type": "string", "x-question-type": "text"}}}`, + "forms/user/ui.json": "{}", + }, + wantErr: false, + }, + { + name: "invalid JSON in schema", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": "invalid json {", + "forms/user/ui.json": "{}", + }, + wantErr: true, + errMsg: "invalid JSON", + }, + { + name: "invalid JSON in ui", + files: map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object"}`, + "forms/user/ui.json": "invalid json {", + }, + wantErr: true, + errMsg: "invalid JSON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundlePath := createTestBundle(t, tt.files) + defer os.Remove(bundlePath) + + err := ValidateBundle(bundlePath) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateBundle() expected error but got none") + return + } + if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateBundle() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + } + } + }) + } +} + +func TestGetBundleInfo(t *testing.T) { + files := map[string]string{ + "app/index.html": "", + "forms/user/schema.json": `{"type": "object"}`, + "forms/user/ui.json": "{}", + "forms/admin/schema.json": `{"type": "object"}`, + "forms/admin/ui.json": "{}", + "renderers/button/renderer.jsx": "export default function Button() {}", + "renderers/custom/renderer.jsx": "export default function Custom() {}", + } + + bundlePath := createTestBundle(t, files) + defer os.Remove(bundlePath) + + info, err := GetBundleInfo(bundlePath) + if err != nil { + t.Fatalf("GetBundleInfo() error = %v", err) + } + + if info["file_count"].(int) != 7 { + t.Errorf("GetBundleInfo() file_count = %v, want 7", info["file_count"]) + } + if info["form_count"].(int) != 2 { + t.Errorf("GetBundleInfo() form_count = %v, want 2", info["form_count"]) + } + if info["renderer_count"].(int) != 2 { + t.Errorf("GetBundleInfo() renderer_count = %v, want 2", info["renderer_count"]) + } + if !info["has_app_index"].(bool) { + t.Errorf("GetBundleInfo() has_app_index = %v, want true", info["has_app_index"]) + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + From a5477927d3798aa0b9b3c95442ff5c36b36ca323 Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Fri, 5 Dec 2025 10:12:30 +0300 Subject: [PATCH 2/2] fix: validate schema.json files only under forms/ directory - Add forms/ prefix check before processing schema.json files - Ensure path format is exactly forms/{formName}/schema.json - Add test case to verify schema.json files outside forms/ are ignored - Prevents incorrect validation of schema.json files in unexpected locations Fixes issue where validator would process arbitrary schema.json files outside the forms/ directory, causing incorrect validation behavior. --- synkronus-cli/pkg/validation/bundle.go | 11 +++++++++-- synkronus-cli/pkg/validation/bundle_test.go | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/synkronus-cli/pkg/validation/bundle.go b/synkronus-cli/pkg/validation/bundle.go index 946b23ec0..c8652474d 100644 --- a/synkronus-cli/pkg/validation/bundle.go +++ b/synkronus-cli/pkg/validation/bundle.go @@ -163,7 +163,15 @@ func validateFormRendererReferences(zipReader *zip.Reader) error { // Then check all form schemas for renderer references for _, file := range zipReader.File { - if strings.HasSuffix(file.Name, "schema.json") { + // Only process schema.json files that are under the forms/ directory + // Verify the path format: forms/{formName}/schema.json + if strings.HasPrefix(file.Name, "forms/") && strings.HasSuffix(file.Name, "schema.json") { + parts := strings.Split(file.Name, "/") + // Ensure it's exactly forms/{formName}/schema.json (3 parts) + if len(parts) != 3 || parts[2] != "schema.json" { + continue // Skip invalid paths (should have been caught earlier, but be safe) + } + // Open the file f, err := file.Open() if err != nil { @@ -320,4 +328,3 @@ func GetBundleInfo(bundlePath string) (map[string]interface{}, error) { return info, nil } - diff --git a/synkronus-cli/pkg/validation/bundle_test.go b/synkronus-cli/pkg/validation/bundle_test.go index 70c782411..67fbbafce 100644 --- a/synkronus-cli/pkg/validation/bundle_test.go +++ b/synkronus-cli/pkg/validation/bundle_test.go @@ -174,6 +174,16 @@ func TestValidateBundle(t *testing.T) { wantErr: true, errMsg: "invalid JSON", }, + { + name: "schema.json outside forms/ directory should be ignored", + files: map[string]string{ + "app/index.html": "", + "app/schema.json": `{"type": "object", "properties": {"field": {"type": "string", "x-question-type": "missing-renderer"}}}`, + "forms/user/schema.json": `{"type": "object", "properties": {"name": {"type": "string", "x-question-type": "text"}}}`, + "forms/user/ui.json": "{}", + }, + wantErr: false, // Should pass because schema.json files outside forms/ are ignored (app/schema.json should not be processed) + }, } for _, tt := range tests {