This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/extension_test.go
Jeffrey Smith 5836ddad21
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / build-and-deploy (push) Has been cancelled
CI/CD / test-sqlite (push) Has been cancelled
CI/CD / test-go-pg (push) Has been cancelled
Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-26 17:30:36 +00:00

1025 lines
32 KiB
Go

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(stores))
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, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-admin", "ext-admin@test.com", "$2a$10$test", "ext-admin", "builtin",
)
database.SeedEveryoneGroupMember(t, adminID)
database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "ext-admin@test.com", "admin")
// Seed regular user
userID := seedInsertReturningID(t,
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-user", "ext-user@test.com", "$2a$10$test", "ext-user", "builtin",
)
database.SeedEveryoneGroupMember(t, userID)
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")
}
}