1022 lines
32 KiB
Go
1022 lines
32 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
|
sqlite "git.gobha.me/xcaliber/chat-switchboard/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")
|
|
}
|
|
}
|