package handlers import ( "archive/zip" "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "armature/database" "armature/store" ) // ── Helpers ──────────────────────────────────── // buildPkgBytes creates a .pkg archive in memory and returns the ZIP bytes. func buildPkgBytes(t *testing.T, manifest map[string]any) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) data, _ := json.Marshal(manifest) w, _ := zw.Create("manifest.json") w.Write(data) zw.Close() return buf.Bytes() } // seedPackage installs a package directly into the DB for testing. func seedPackage(t *testing.T, stores store.Stores, manifest map[string]any) { t.Helper() ctx := context.Background() id := manifest["id"].(string) title := manifest["title"].(string) source := "extension" if s, ok := manifest["source"].(string); ok { source = s } if err := stores.Packages.Seed(ctx, id, title, source, manifest); err != nil { t.Fatal(err) } version, _ := manifest["version"].(string) if version == "" { version = "0.0.0" } pkgType, _ := manifest["type"].(string) if pkgType == "" { pkgType = "surface" } tier, _ := manifest["tier"].(string) if tier == "" { tier = "browser" } pkg := &store.PackageRegistration{ Title: title, Type: pkgType, Version: version, Tier: tier, Enabled: true, Manifest: manifest, } if err := stores.Packages.Update(ctx, id, pkg); err != nil { t.Fatalf("seedPackage Update failed for %s: %v", id, err) } // Verify version was actually stored check, _ := stores.Packages.Get(ctx, id) if check != nil && check.Version != version { t.Fatalf("seedPackage: version not stored correctly for %s: got %q want %q", id, check.Version, version) } } // setupUpdateRouter creates a gin router with the update and export routes. func setupUpdateRouter(t *testing.T, stores store.Stores) *gin.Engine { t.Helper() gin.SetMode(gin.TestMode) r := gin.New() handler := NewPackageHandler(stores) r.POST("/api/v1/admin/packages/:id/update", handler.UpdatePackage) r.GET("/api/v1/admin/packages/:id/export", handler.ExportPackage) return r } // doUpdate performs a multipart upload to the update endpoint. func doUpdate(router *gin.Engine, pkgID string, pkgBytes []byte) *httptest.ResponseRecorder { body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormFile("file", "test.pkg") part.Write(pkgBytes) writer.Close() req := httptest.NewRequest("POST", "/api/v1/admin/packages/"+pkgID+"/update", body) req.Header.Set("Content-Type", writer.FormDataContentType()) w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } // ── Tests ──────────────────────────────────── func TestUpdatePackage_VersionBump(t *testing.T) { stores := newTestStores(t) ctx := context.Background() seedPackage(t, stores, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.1.0", }) w := doUpdate(router, "test-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify version updated updated, _ := stores.Packages.Get(ctx, "test-pkg") if updated.Version != "1.1.0" { t.Errorf("version = %q, want %q", updated.Version, "1.1.0") } // Check response body var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) if resp["version"] != "1.1.0" { t.Errorf("response version = %v, want 1.1.0", resp["version"]) } if resp["previous_version"] != "1.0.0" { t.Errorf("response previous_version = %v, want 1.0.0", resp["previous_version"]) } } func TestUpdatePackage_SameVersion_Rejected(t *testing.T) { stores := newTestStores(t) seedPackage(t, stores, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) w := doUpdate(router, "test-pkg", pkg) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_OlderVersion_Rejected(t *testing.T) { stores := newTestStores(t) seedPackage(t, stores, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "2.0.0", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) w := doUpdate(router, "test-pkg", pkg) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_CorePackage_Rejected(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Seed as core package stores.Packages.Seed(ctx, "core-pkg", "Core", "core", map[string]any{ "id": "core-pkg", "title": "Core", "type": "surface", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "core-pkg", "title": "Core", "type": "surface", "version": "2.0.0", }) w := doUpdate(router, "core-pkg", pkg) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_TypeMismatch_Rejected(t *testing.T) { stores := newTestStores(t) seedPackage(t, stores, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "test-pkg", "title": "Test", "type": "extension", "version": "2.0.0", "tools": []any{map[string]any{"name": "test"}}, }) w := doUpdate(router, "test-pkg", pkg) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_IDMismatch_Rejected(t *testing.T) { stores := newTestStores(t) seedPackage(t, stores, map[string]any{ "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", }) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "other-pkg", "title": "Other", "type": "surface", "version": "2.0.0", }) w := doUpdate(router, "test-pkg", pkg) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_NotFound(t *testing.T) { stores := newTestStores(t) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "nonexistent", "title": "Test", "type": "surface", "version": "1.0.0", }) w := doUpdate(router, "nonexistent", pkg) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) } } func TestUpdatePackage_SchemaAddColumn(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install v1 with a table that has one column seedPackage(t, stores, map[string]any{ "id": "schema-pkg", "title": "Schema Test", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text"}, }, }, }) // Actually create the table tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text"}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "schema-pkg", tables) // Insert a row to verify data preservation physical := extPhysicalTable("schema-pkg", "items") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, name) VALUES ('row1', 'hello')", physical)) if err != nil { t.Fatal(err) } // Update to v2 with an added column router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "schema-pkg", "title": "Schema Test", "type": "surface", "version": "2.0.0", "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "priority": "int"}, }, }, }) w := doUpdate(router, "schema-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify new column exists and old data preserved var name string var priority sql.NullInt64 err = database.TestDB.QueryRowContext(ctx, fmt.Sprintf("SELECT name, priority FROM %s WHERE id = 'row1'", physical)). Scan(&name, &priority) if err != nil { t.Fatal("query failed after schema migration:", err) } if name != "hello" { t.Errorf("name = %q, want %q (data should be preserved)", name, "hello") } } func TestUpdatePackage_SchemaAddTable(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install v1 with no tables seedPackage(t, stores, map[string]any{ "id": "schema-pkg-b", "title": "Schema B", "type": "surface", "version": "1.0.0", }) // Update to v2 with a new table router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "schema-pkg-b", "title": "Schema B", "type": "surface", "version": "2.0.0", "db_tables": map[string]any{ "audit": map[string]any{ "columns": map[string]any{"action": "text", "actor": "text"}, }, }, }) w := doUpdate(router, "schema-pkg-b", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify table was created physical := extPhysicalTable("schema-pkg-b", "audit") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, action, actor) VALUES ('row1', 'test', 'user1')", physical)) if err != nil { t.Fatal("new table should exist after update:", err) } } func TestUpdatePackage_SettingsMerge(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install with initial settings seedPackage(t, stores, map[string]any{ "id": "settings-pkg", "title": "Settings Test", "type": "surface", "version": "1.0.0", "settings": []any{ map[string]any{"key": "color", "type": "text", "default": "blue"}, }, }) // Set existing settings value existing := json.RawMessage(`{"color":"red"}`) stores.Packages.SetPackageSettings(ctx, "settings-pkg", existing) // Update with new settings key router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "settings-pkg", "title": "Settings Test", "type": "surface", "version": "2.0.0", "settings": []any{ map[string]any{"key": "color", "type": "text", "default": "blue"}, map[string]any{"key": "size", "type": "int", "default": float64(12)}, }, }) w := doUpdate(router, "settings-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify settings: color preserved as "red", size added with default 12 raw, _ := stores.Packages.GetPackageSettings(ctx, "settings-pkg") var settings map[string]any json.Unmarshal(raw, &settings) if settings["color"] != "red" { t.Errorf("color = %v, want %q (existing should be preserved)", settings["color"], "red") } if settings["size"] != float64(12) { t.Errorf("size = %v, want %v (new key should get default)", settings["size"], 12) } } func TestExportPackage_Basic(t *testing.T) { stores := newTestStores(t) seedPackage(t, stores, map[string]any{ "id": "export-pkg", "title": "Export Test", "type": "surface", "version": "1.0.0", }) router := setupUpdateRouter(t, stores) req := httptest.NewRequest("GET", "/api/v1/admin/packages/export-pkg/export", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify response is a valid zip with manifest.json zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) if err != nil { t.Fatal("response is not a valid zip:", err) } foundManifest := false for _, f := range zr.File { if f.Name == "manifest.json" { foundManifest = true rc, _ := f.Open() data, _ := io.ReadAll(rc) rc.Close() var m map[string]any if json.Unmarshal(data, &m) != nil { t.Fatal("manifest.json is not valid JSON") } if m["id"] != "export-pkg" { t.Errorf("manifest id = %v, want export-pkg", m["id"]) } } } if !foundManifest { t.Error("zip does not contain manifest.json") } } func TestExportPackage_NotFound(t *testing.T) { stores := newTestStores(t) router := setupUpdateRouter(t, stores) req := httptest.NewRequest("GET", "/api/v1/admin/packages/nonexistent/export", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } // TestBundledInstall_DefaultAllowlist verifies that an empty allowlist // installs nothing (defaultBundledPackages is empty since v0.6.17). func TestBundledInstall_DefaultAllowlist(t *testing.T) { stores := newTestStores(t) ctx := context.Background() bundledDir := t.TempDir() packagesDir := t.TempDir() // Create packages — neither should be installed with empty defaults buildTestPkg(t, bundledDir, map[string]any{ "id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0", }) buildTestPkg(t, bundledDir, map[string]any{ "id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0", }) // Empty allowlist → empty default set → nothing installed InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) pkg, _ := stores.Packages.Get(ctx, "notes") if pkg != nil { t.Fatal("notes should NOT be installed (default set is empty)") } pkg, _ = stores.Packages.Get(ctx, "regex-tester") if pkg != nil { t.Fatal("regex-tester should NOT be installed (default set is empty)") } } // TestBundledInstall_WildcardAllowlist verifies "*" installs everything. func TestBundledInstall_WildcardAllowlist(t *testing.T) { stores := newTestStores(t) ctx := context.Background() bundledDir := t.TempDir() packagesDir := t.TempDir() buildTestPkg(t, bundledDir, map[string]any{ "id": "any-pkg", "title": "Any", "type": "surface", "version": "1.0.0", }) InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) pkg, err := stores.Packages.Get(ctx, "any-pkg") if err != nil || pkg == nil { t.Fatal("any-pkg should be installed with * wildcard") } }