package handlers import ( "context" "database/sql" "encoding/json" "net/http" "os" "path/filepath" "testing" "github.com/gin-gonic/gin" authpkg "switchboard-core/auth" "switchboard-core/config" "switchboard-core/database" "switchboard-core/middleware" "switchboard-core/store" postgres "switchboard-core/store/postgres" sqlite "switchboard-core/store/sqlite" ) // ── Extension Test Harness ────────────────── type extensionHarness struct { *testHarness adminToken string adminID string userToken string userID string stores store.Stores } func setupExtensionHarness(t *testing.T) *extensionHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") // Auth (for token generation) auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) api.POST("/auth/register", auth.Register) api.POST("/auth/login", auth.Login) // Public extension assets (no auth) extH := NewExtensionHandler(stores) api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset) // Protected routes protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // User extension endpoints protected.GET("/extensions", extH.ListUserExtensions) protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings) protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest) protected.GET("/extensions/tools", extH.ListBrowserToolSchemas) // Admin extension endpoints admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.GET("/extensions", extH.AdminListExtensions) admin.POST("/extensions", extH.AdminInstallExtension) admin.PUT("/extensions/:id", extH.AdminUpdateExtension) admin.DELETE("/extensions/:id", extH.AdminUninstallExtension) // Seed admin user adminID := seedInsertReturningID(t, `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, "ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", ) adminToken := makeToken(adminID, "ext-admin@test.com", "admin") // Seed regular user userID := seedInsertReturningID(t, `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, "ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin", ) userToken := makeToken(userID, "ext-user@test.com", "user") return &extensionHarness{ testHarness: &testHarness{router: r, t: t}, adminToken: adminToken, adminID: adminID, userToken: userToken, userID: userID, stores: stores, } } // installExtension is a helper that installs an extension via the admin API. func (h *extensionHarness) installExtension(extID, name string, extra map[string]interface{}) map[string]interface{} { h.t.Helper() body := map[string]interface{}{ "ext_id": extID, "name": name, "is_enabled": true, } for k, v := range extra { body[k] = v } w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, body) if w.Code != http.StatusCreated { h.t.Fatalf("installExtension(%s): want 201, got %d: %s", extID, w.Code, w.Body.String()) } var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) data, ok := resp["data"].(map[string]interface{}) if !ok { h.t.Fatalf("installExtension: response missing data wrapper") } return data } // ═══════════════════════════════════════════════ // Admin CRUD Lifecycle // ═══════════════════════════════════════════════ func TestExtension_AdminCRUDLifecycle(t *testing.T) { h := setupExtensionHarness(t) // ── Install ───────────────────────── ext := h.installExtension("test-ext", "Test Extension", map[string]interface{}{ "version": "1.0.0", "description": "A test extension", "author": "tester", "manifest": map[string]interface{}{"_script": "console.log('hello')"}, }) extID := ext["id"].(string) if extID == "" { t.Fatal("extension should have an id") } if ext["version"].(string) != "1.0.0" { t.Fatalf("version mismatch: got %q", ext["version"]) } if ext["tier"].(string) != "browser" { t.Fatalf("tier should default to browser, got %q", ext["tier"]) } // ── Admin List ────────────────────── w := h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin list: want 200, got %d", w.Code) } var listResp map[string]interface{} decode(w, &listResp) data := listResp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("expected 1 extension, got %d", len(data)) } // ── Update ────────────────────────── w = h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ "name": "Updated Extension", "version": "2.0.0", }) if w.Code != http.StatusOK { t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String()) } var updated map[string]interface{} decode(w, &updated) updatedData := updated["data"].(map[string]interface{}) if updatedData["title"].(string) != "Updated Extension" { t.Fatalf("name not updated: got %q", updatedData["title"]) } if updatedData["version"].(string) != "2.0.0" { t.Fatalf("version not updated: got %q", updatedData["version"]) } // ── Delete ────────────────────────── w = h.request("DELETE", "/api/v1/admin/extensions/"+extID, h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete: want 200, got %d", w.Code) } // Verify gone w = h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) decode(w, &listResp) data = listResp["data"].([]interface{}) if len(data) != 0 { t.Fatalf("expected 0 extensions after delete, got %d", len(data)) } } // ═══════════════════════════════════════════════ // Admin Install Validation // ═══════════════════════════════════════════════ func TestExtension_DuplicateExtID(t *testing.T) { h := setupExtensionHarness(t) h.installExtension("dup-ext", "First", nil) // Second install with same ext_id → 409 w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ "ext_id": "dup-ext", "name": "Second", }) if w.Code != http.StatusConflict { t.Fatalf("duplicate ext_id: want 409, got %d: %s", w.Code, w.Body.String()) } } func TestExtension_InvalidTier(t *testing.T) { h := setupExtensionHarness(t) w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ "ext_id": "bad-tier", "name": "Bad Tier", "tier": "invalid", }) if w.Code != http.StatusBadRequest { t.Fatalf("invalid tier: want 400, got %d: %s", w.Code, w.Body.String()) } } func TestExtension_ValidTiers(t *testing.T) { h := setupExtensionHarness(t) for _, tier := range []string{"browser", "starlark", "sidecar"} { ext := h.installExtension(tier+"-ext", tier+" Extension", map[string]interface{}{ "tier": tier, }) if ext["tier"].(string) != tier { t.Fatalf("tier mismatch for %s: got %q", tier, ext["tier"]) } } } func TestExtension_MissingRequiredFields(t *testing.T) { h := setupExtensionHarness(t) // Missing ext_id w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ "name": "No ExtID", }) if w.Code != http.StatusBadRequest { t.Fatalf("missing ext_id: want 400, got %d", w.Code) } // Missing name w = h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ "ext_id": "no-name", }) if w.Code != http.StatusBadRequest { t.Fatalf("missing name: want 400, got %d", w.Code) } } func TestExtension_UpdateNotFound(t *testing.T) { h := setupExtensionHarness(t) w := h.request("PUT", "/api/v1/admin/extensions/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{ "name": "Ghost", }) if w.Code != http.StatusNotFound { t.Fatalf("update nonexistent: want 404, got %d", w.Code) } } func TestExtension_DeleteNotFound(t *testing.T) { h := setupExtensionHarness(t) w := h.request("DELETE", "/api/v1/admin/extensions/00000000-0000-0000-0000-000000000000", h.adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("delete nonexistent: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // User List + Tier Filter // ═══════════════════════════════════════════════ func TestExtension_UserListWithTierFilter(t *testing.T) { h := setupExtensionHarness(t) h.installExtension("browser-ext", "Browser One", map[string]interface{}{"tier": "browser"}) h.installExtension("starlark-ext", "Starlark One", map[string]interface{}{"tier": "starlark"}) // List all w := h.request("GET", "/api/v1/extensions", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list all: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) all := resp["data"].([]interface{}) if len(all) != 2 { t.Fatalf("expected 2 extensions, got %d", len(all)) } // Filter by tier=browser w = h.request("GET", "/api/v1/extensions?tier=browser", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list filtered: want 200, got %d", w.Code) } decode(w, &resp) filtered := resp["data"].([]interface{}) if len(filtered) != 1 { t.Fatalf("expected 1 browser extension, got %d", len(filtered)) } } func TestExtension_UserListEmptyArray(t *testing.T) { h := setupExtensionHarness(t) // No extensions installed — should return [] not null w := h.request("GET", "/api/v1/extensions", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list empty: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if data == nil { t.Fatal("data should be [] not null") } if len(data) != 0 { t.Fatalf("expected 0 extensions, got %d", len(data)) } } // ═══════════════════════════════════════════════ // User Settings // ═══════════════════════════════════════════════ func TestExtension_UserSettingsUpsert(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("settings-ext", "Settings Test", nil) extID := ext["id"].(string) // Save settings (insert) w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "settings": map[string]interface{}{"theme": "dark"}, "is_enabled": true, }) if w.Code != http.StatusOK { t.Fatalf("save settings: want 200, got %d: %s", w.Code, w.Body.String()) } // Update settings (upsert — same PK) w = h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "settings": map[string]interface{}{"theme": "light", "compact": true}, }) if w.Code != http.StatusOK { t.Fatalf("update settings: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify via user list — settings should be merged w = h.request("GET", "/api/v1/extensions", h.userToken, nil) var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("expected 1 extension, got %d", len(data)) } extData := data[0].(map[string]interface{}) if extData["user_settings"] == nil { t.Fatal("user_settings should be present after saving") } } func TestExtension_SystemExtensionCannotBeDisabled(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("sys-ext", "System Extension", map[string]interface{}{ "is_system": true, }) extID := ext["id"].(string) // Try to disable → 403 w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "is_enabled": false, }) if w.Code != http.StatusForbidden { t.Fatalf("disable system ext: want 403, got %d: %s", w.Code, w.Body.String()) } } func TestExtension_SettingsNotFoundExtension(t *testing.T) { h := setupExtensionHarness(t) w := h.request("POST", "/api/v1/extensions/00000000-0000-0000-0000-000000000000/settings", h.userToken, map[string]interface{}{ "is_enabled": true, }) if w.Code != http.StatusNotFound { t.Fatalf("settings on nonexistent ext: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // User Disable/Re-enable // ═══════════════════════════════════════════════ func TestExtension_UserDisableHidesFromList(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("toggle-ext", "Toggle Test", nil) extID := ext["id"].(string) // Disable via user settings w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "is_enabled": false, }) if w.Code != http.StatusOK { t.Fatalf("disable: want 200, got %d", w.Code) } // User list should be empty (extension hidden) w = h.request("GET", "/api/v1/extensions", h.userToken, nil) var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if len(data) != 0 { t.Fatalf("expected 0 after disable, got %d", len(data)) } // Admin list should still show it w = h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) decode(w, &resp) data = resp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("admin should still see 1, got %d", len(data)) } // Re-enable w = h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "is_enabled": true, }) if w.Code != http.StatusOK { t.Fatalf("re-enable: want 200, got %d", w.Code) } // Should reappear w = h.request("GET", "/api/v1/extensions", h.userToken, nil) decode(w, &resp) data = resp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("expected 1 after re-enable, got %d", len(data)) } } // ═══════════════════════════════════════════════ // Manifest Endpoint // ═══════════════════════════════════════════════ func TestExtension_GetManifest(t *testing.T) { h := setupExtensionHarness(t) h.installExtension("manifest-ext", "Manifest Test", map[string]interface{}{ "manifest": map[string]interface{}{ "tools": []map[string]interface{}{ {"name": "my_tool", "description": "does stuff"}, }, }, }) // Manifest endpoint uses ext_id, not UUID w := h.request("GET", "/api/v1/extensions/manifest-ext/manifest", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("get manifest: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) if resp["data"] == nil { t.Fatal("manifest should be in data wrapper") } } func TestExtension_GetManifestNotFound(t *testing.T) { h := setupExtensionHarness(t) w := h.request("GET", "/api/v1/extensions/nonexistent/manifest", h.userToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("manifest not found: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // Tool Schema Endpoint // ═══════════════════════════════════════════════ func TestExtension_ToolSchemas(t *testing.T) { h := setupExtensionHarness(t) // Install extension with tools in manifest h.installExtension("tool-ext", "Tool Extension", map[string]interface{}{ "manifest": map[string]interface{}{ "tools": []map[string]interface{}{ {"name": "tool_a", "description": "Tool A"}, {"name": "tool_b", "description": "Tool B"}, }, }, }) w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("tools: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) tools := resp["data"].([]interface{}) if len(tools) != 2 { t.Fatalf("expected 2 tool schemas, got %d", len(tools)) } } func TestExtension_ToolSchemasEmpty(t *testing.T) { h := setupExtensionHarness(t) // No extensions → empty array, not null w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("tools empty: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) tools := resp["data"].([]interface{}) if tools == nil { t.Fatal("tools should be [] not null") } if len(tools) != 0 { t.Fatalf("expected 0 tools, got %d", len(tools)) } } func TestExtension_ToolSchemasSkipsNonBrowser(t *testing.T) { h := setupExtensionHarness(t) // Starlark extension with tools should not appear in browser tool list h.installExtension("starlark-tool-ext", "Starlark Tool", map[string]interface{}{ "tier": "starlark", "manifest": map[string]interface{}{ "tools": []map[string]interface{}{ {"name": "server_tool", "description": "Server-side"}, }, }, }) w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) var resp map[string]interface{} decode(w, &resp) tools := resp["data"].([]interface{}) if len(tools) != 0 { t.Fatalf("starlark tools should not appear: got %d", len(tools)) } } // ═══════════════════════════════════════════════ // Asset Serving // ═══════════════════════════════════════════════ func TestExtension_AssetServing(t *testing.T) { h := setupExtensionHarness(t) h.installExtension("asset-ext", "Asset Test", map[string]interface{}{ "manifest": map[string]interface{}{ "_script": "console.log('extension loaded');", }, }) // Asset endpoint is public (no token), uses ext_id w := h.request("GET", "/api/v1/extensions/asset-ext/assets/main.js", "", nil) if w.Code != http.StatusOK { t.Fatalf("asset: want 200, got %d: %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "application/javascript; charset=utf-8" { t.Fatalf("content-type: got %q", ct) } body := w.Body.String() if body != "console.log('extension loaded');" { t.Fatalf("script body mismatch: got %q", body) } } func TestExtension_AssetDisabledExtension(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("disabled-ext", "Disabled", map[string]interface{}{ "manifest": map[string]interface{}{"_script": "nope"}, }) extID := ext["id"].(string) // Disable the extension via admin w := h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ "is_enabled": false, }) if w.Code != http.StatusOK { t.Fatalf("disable: want 200, got %d", w.Code) } // Asset should 404 w = h.request("GET", "/api/v1/extensions/disabled-ext/assets/main.js", "", nil) if w.Code != http.StatusNotFound { t.Fatalf("disabled asset: want 404, got %d", w.Code) } } func TestExtension_AssetNotFound(t *testing.T) { h := setupExtensionHarness(t) w := h.request("GET", "/api/v1/extensions/nonexistent/assets/main.js", "", nil) if w.Code != http.StatusNotFound { t.Fatalf("asset not found: want 404, got %d", w.Code) } } func TestExtension_AssetNoScript(t *testing.T) { h := setupExtensionHarness(t) // Install extension without _script in manifest h.installExtension("no-script-ext", "No Script", map[string]interface{}{ "manifest": map[string]interface{}{"tools": []string{}}, }) w := h.request("GET", "/api/v1/extensions/no-script-ext/assets/main.js", "", nil) if w.Code != http.StatusNotFound { t.Fatalf("no script: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // Admin Auth Enforcement // ═══════════════════════════════════════════════ func TestExtension_AdminEndpointsRequireAdmin(t *testing.T) { h := setupExtensionHarness(t) // Regular user → 403 on admin endpoints endpoints := []struct { method string path string }{ {"GET", "/api/v1/admin/extensions"}, {"POST", "/api/v1/admin/extensions"}, } for _, ep := range endpoints { w := h.request(ep.method, ep.path, h.userToken, map[string]interface{}{ "ext_id": "nope", "name": "nope", }) if w.Code != http.StatusForbidden { t.Fatalf("%s %s with user token: want 403, got %d", ep.method, ep.path, w.Code) } } } // ═══════════════════════════════════════════════ // Partial Update // ═══════════════════════════════════════════════ func TestExtension_PartialUpdate(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("partial-ext", "Original Name", map[string]interface{}{ "version": "1.0.0", "description": "Original desc", "author": "Original author", }) extID := ext["id"].(string) // Update only name — other fields should stay w := h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ "name": "New Name", }) if w.Code != http.StatusOK { t.Fatalf("partial update: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) data := resp["data"].(map[string]interface{}) if data["title"].(string) != "New Name" { t.Fatalf("name not updated: got %q", data["title"]) } if data["version"].(string) != "1.0.0" { t.Fatalf("version should be unchanged: got %q", data["version"]) } if data["description"].(string) != "Original desc" { t.Fatalf("description should be unchanged: got %q", data["description"]) } if data["author"].(string) != "Original author" { t.Fatalf("author should be unchanged: got %q", data["author"]) } } // ═══════════════════════════════════════════════ // Store-Level Tests (forward-looking methods) // ═══════════════════════════════════════════════ func TestExtension_StoreListEnabled(t *testing.T) { h := setupExtensionHarness(t) h.installExtension("enabled-ext", "Enabled", map[string]interface{}{"is_enabled": true}) ext2 := h.installExtension("will-disable", "Will Disable", nil) // Disable one via admin w := h.request("PUT", "/api/v1/admin/extensions/"+ext2["id"].(string), h.adminToken, map[string]interface{}{ "is_enabled": false, }) if w.Code != http.StatusOK { t.Fatalf("disable: want 200, got %d", w.Code) } // ListEnabled should return only the enabled one ctx := context.Background() pkgs, err := h.stores.Packages.ListEnabledByType(ctx, "extension") if err != nil { t.Fatalf("ListEnabled: %v", err) } if len(pkgs) != 1 { t.Fatalf("expected 1 enabled, got %d", len(pkgs)) } if pkgs[0].ID != "enabled-ext" { t.Fatalf("wrong extension: got %q", pkgs[0].ID) } } func TestExtension_StoreGetUserSettings(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("settings-store-ext", "Settings Store", nil) extID := ext["id"].(string) ctx := context.Background() // No settings yet → sql.ErrNoRows _, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID) if err != sql.ErrNoRows { t.Fatalf("expected ErrNoRows, got %v", err) } // Save settings via handler w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "settings": map[string]interface{}{"key": "value"}, "is_enabled": true, }) if w.Code != http.StatusOK { t.Fatalf("save: %d", w.Code) } // Now GetUserSettings should return them pus, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID) if err != nil { t.Fatalf("GetUserSettings: %v", err) } if !pus.IsEnabled { t.Fatal("should be enabled") } var settings map[string]interface{} json.Unmarshal(pus.Settings, &settings) if settings["key"] != "value" { t.Fatalf("settings mismatch: %v", settings) } } func TestExtension_StoreDeleteUserSettings(t *testing.T) { h := setupExtensionHarness(t) ext := h.installExtension("del-settings-ext", "Del Settings", nil) extID := ext["id"].(string) ctx := context.Background() // Save settings w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ "settings": map[string]interface{}{"remove": "me"}, "is_enabled": true, }) if w.Code != http.StatusOK { t.Fatalf("save: %d", w.Code) } // Verify present _, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID) if err != nil { t.Fatalf("should exist: %v", err) } // Delete via store err = h.stores.Packages.DeleteUserSettings(ctx, extID, h.userID) if err != nil { t.Fatalf("DeleteUserSettings: %v", err) } // Verify gone _, err = h.stores.Packages.GetUserSettings(ctx, extID, h.userID) if err != sql.ErrNoRows { t.Fatalf("should be gone, got %v", err) } } // ═══════════════════════════════════════════════ // Seed Logic (F26/F27) // ═══════════════════════════════════════════════ // makeSeedDir creates a temp directory with extension subdirectories. // Each entry in exts maps a dir name to {manifest, script}. func makeSeedDir(t *testing.T, exts map[string]struct{ manifest, script string }) string { t.Helper() dir := t.TempDir() for name, files := range exts { extDir := filepath.Join(dir, name) if err := os.MkdirAll(extDir, 0755); err != nil { t.Fatalf("mkdir %s: %v", extDir, err) } if files.manifest != "" { if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(files.manifest), 0644); err != nil { t.Fatalf("write manifest: %v", err) } } if files.script != "" { if err := os.WriteFile(filepath.Join(extDir, "script.js"), []byte(files.script), 0644); err != nil { t.Fatalf("write script: %v", err) } } } return dir } func TestSeed_NewExtension(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "test-seeded": { manifest: `{"id":"test-seeded","name":"Seeded Extension","version":"1.0.0","tier":"browser","author":"test"}`, script: `console.log("seeded");`, }, }) SeedBuiltinPackages(h.stores, dir) // Should be created as system extension pkg, err := h.stores.Packages.Get(ctx, "test-seeded") if err != nil { t.Fatalf("seeded extension not found: %v", err) } if pkg.Title != "Seeded Extension" { t.Fatalf("name: got %q", pkg.Title) } if pkg.Version != "1.0.0" { t.Fatalf("version: got %q", pkg.Version) } if !pkg.IsSystem { t.Fatal("should be system extension") } if !pkg.Enabled { t.Fatal("should be enabled") } // Verify _script is inlined in manifest if _, ok := pkg.Manifest["_script"]; !ok { t.Fatal("manifest should contain _script") } } func TestSeed_SameVersionSkips(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "skip-ext": { manifest: `{"id":"skip-ext","name":"Skip Test","version":"1.0.0"}`, script: `console.log("v1");`, }, }) // Seed twice SeedBuiltinPackages(h.stores, dir) SeedBuiltinPackages(h.stores, dir) // Should still have exactly one pkgs, err := h.stores.Packages.List(ctx) if err != nil { t.Fatalf("list: %v", err) } count := 0 for _, p := range pkgs { if p.ID == "skip-ext" { count++ } } if count != 1 { t.Fatalf("expected 1 skip-ext, got %d", count) } } func TestSeed_VersionChangeUpdates(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() // Seed v1 dir1 := makeSeedDir(t, map[string]struct{ manifest, script string }{ "versioned-ext": { manifest: `{"id":"versioned-ext","name":"V1 Name","version":"1.0.0","author":"original"}`, script: `console.log("v1");`, }, }) SeedBuiltinPackages(h.stores, dir1) pkg, _ := h.stores.Packages.Get(ctx, "versioned-ext") if pkg.Version != "1.0.0" { t.Fatalf("v1 version: got %q", pkg.Version) } // Seed v2 (different version triggers update) dir2 := makeSeedDir(t, map[string]struct{ manifest, script string }{ "versioned-ext": { manifest: `{"id":"versioned-ext","name":"V2 Name","version":"2.0.0","author":"updated"}`, script: `console.log("v2");`, }, }) SeedBuiltinPackages(h.stores, dir2) pkg, _ = h.stores.Packages.Get(ctx, "versioned-ext") if pkg.Version != "2.0.0" { t.Fatalf("v2 version: got %q", pkg.Version) } if pkg.Title != "V2 Name" { t.Fatalf("v2 name: got %q", pkg.Title) } } func TestSeed_MissingManifestSkips(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() // Directory with no manifest.json dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "no-manifest": {script: `console.log("orphan");`}, }) SeedBuiltinPackages(h.stores, dir) pkgs, err := h.stores.Packages.List(ctx) if err != nil { t.Fatalf("list: %v", err) } if len(pkgs) != 0 { t.Fatalf("should skip dir without manifest, got %d extensions", len(pkgs)) } } func TestSeed_InvalidManifestSkips(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "bad-manifest": {manifest: `{not valid json`, script: `console.log("bad");`}, }) SeedBuiltinPackages(h.stores, dir) pkgs, _ := h.stores.Packages.List(ctx) if len(pkgs) != 0 { t.Fatalf("should skip invalid manifest, got %d extensions", len(pkgs)) } } func TestSeed_MissingIDSkips(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() // Valid JSON but no "id" field dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "no-id": {manifest: `{"name":"No ID Extension","version":"1.0.0"}`, script: `console.log("no id");`}, }) SeedBuiltinPackages(h.stores, dir) pkgs, _ := h.stores.Packages.List(ctx) if len(pkgs) != 0 { t.Fatalf("should skip manifest without id, got %d extensions", len(pkgs)) } } func TestSeed_NonexistentDirectorySkips(t *testing.T) { h := setupExtensionHarness(t) // Should not panic on missing directory SeedBuiltinPackages(h.stores, "/nonexistent/path/extensions") pkgs, _ := h.stores.Packages.List(context.Background()) if len(pkgs) != 0 { t.Fatalf("should seed nothing from missing dir, got %d", len(pkgs)) } } func TestSeed_ScriptOptional(t *testing.T) { h := setupExtensionHarness(t) ctx := context.Background() // Manifest-only extension (no script.js) dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ "manifest-only": {manifest: `{"id":"manifest-only","name":"Manifest Only","version":"1.0.0"}`}, }) SeedBuiltinPackages(h.stores, dir) pkg, err := h.stores.Packages.Get(ctx, "manifest-only") if err != nil { t.Fatalf("should create manifest-only ext: %v", err) } // Manifest should NOT contain _script if _, ok := pkg.Manifest["_script"]; ok { t.Fatal("manifest should not contain _script when no script.js exists") } }