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/surfaces_test.go
2026-03-13 09:29:04 +00:00

605 lines
18 KiB
Go

package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"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"
)
// ── Surface Test Harness ────────────────────
type surfaceHarness struct {
router *gin.Engine
t *testing.T
stores store.Stores
adminToken string
userToken string
tmpDir string
}
func setupSurfaceHarness(t *testing.T) *surfaceHarness {
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)
}
tmpDir, err := os.MkdirTemp("", "surface-test-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
r := gin.New()
api := r.Group("/api/v1")
// User endpoint (authenticated)
surfaceH := NewSurfaceHandler(stores)
userGroup := api.Group("")
userGroup.Use(middleware.Auth(cfg))
userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Admin endpoints
surfaceAdm := NewSurfaceHandler(stores, tmpDir)
adminGroup := api.Group("/admin")
adminGroup.Use(middleware.Auth(cfg))
adminGroup.Use(middleware.RequireAdmin())
adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces)
adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface)
adminGroup.POST("/surfaces/install", surfaceAdm.InstallSurface)
adminGroup.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
adminGroup.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
adminGroup.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
// Seed admin user
adminID := database.SeedTestUser(t, "surf-admin", "surf-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "surf-admin@test.com", "admin")
// Seed regular user
userID := database.SeedTestUser(t, "surf-user", "surf-user@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "surf-user@test.com", "user")
return &surfaceHarness{
router: r,
t: t,
stores: stores,
adminToken: adminToken,
userToken: userToken,
tmpDir: tmpDir,
}
}
func (h *surfaceHarness) jsonReq(method, path, token string, body interface{}) *httptest.ResponseRecorder {
h.t.Helper()
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) uploadSurface(token string, archive []byte, filename string) *httptest.ResponseRecorder {
h.t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
h.t.Fatalf("CreateFormFile: %v", err)
}
part.Write(archive)
writer.Close()
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) seedCoreSurface(id, title string) {
h.t.Helper()
manifest := map[string]any{"route": "/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "core", manifest); err != nil {
h.t.Fatalf("seedCoreSurface(%s): %v", id, err)
}
}
func (h *surfaceHarness) seedExtensionSurface(id, title string, enabled bool) {
h.t.Helper()
manifest := map[string]any{"route": "/s/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "extension", manifest); err != nil {
h.t.Fatalf("seedExtensionSurface(%s): %v", id, err)
}
if !enabled {
if err := h.stores.Surfaces.SetEnabled(context.Background(), id, false); err != nil {
h.t.Fatalf("disable extension %s: %v", id, err)
}
}
}
// ── Tests: User Endpoint ────────────────────
func TestSurface_ListEnabled_Empty(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct{ Surfaces []json.RawMessage `json:"surfaces"` }
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 0 {
t.Errorf("want 0 surfaces, got %d", len(resp.Surfaces))
}
}
func TestSurface_ListEnabled_FiltersDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 2 {
t.Fatalf("want 2 enabled surfaces, got %d", len(resp.Surfaces))
}
// Response shape: id + title + route only — no source or enabled
raw := w.Body.Bytes()
if bytes.Contains(raw, []byte(`"source"`)) {
t.Error("user endpoint should not expose 'source' field")
}
if bytes.Contains(raw, []byte(`"enabled"`)) {
t.Error("user endpoint should not expose 'enabled' field")
}
}
// ── Tests: Admin List / Get ─────────────────
func TestSurface_AdminList_IncludesDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 3 {
t.Fatalf("want 3 surfaces (including disabled), got %d", len(resp.Surfaces))
}
}
func TestSurface_AdminList_RequiresAdmin(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.userToken, nil)
if w.Code != http.StatusForbidden {
t.Errorf("non-admin should get 403, got %d", w.Code)
}
}
func TestSurface_Get(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var sr store.SurfaceRegistration
json.Unmarshal(w.Body.Bytes(), &sr)
if sr.ID != "chat" {
t.Errorf("id: want %q, got %q", "chat", sr.ID)
}
if sr.Source != "core" {
t.Errorf("source: want %q, got %q", "core", sr.Source)
}
}
func TestSurface_Get_NotFound(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d", w.Code)
}
}
// ── Tests: Enable / Disable ─────────────────
func TestSurface_EnableDisable(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Disable
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("disable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr.Enabled {
t.Error("surface should be disabled")
}
// Re-enable
w = h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/enable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("enable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ = h.stores.Surfaces.Get(context.Background(), "my-ext")
if !sr.Enabled {
t.Error("surface should be enabled")
}
}
func TestSurface_DisableChat_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/chat/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling chat: want 400, got %d", w.Code)
}
}
func TestSurface_DisableAdmin_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("admin", "Admin")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/admin/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling admin: want 400, got %d", w.Code)
}
}
func TestSurface_DisableNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/nonexistent/disable", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("disable nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Delete ───────────────────────────
func TestSurface_DeleteExtension(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Create a fake asset dir to verify cleanup
assetDir := filepath.Join(h.tmpDir, "my-ext")
os.MkdirAll(filepath.Join(assetDir, "js"), 0755)
os.WriteFile(filepath.Join(assetDir, "js", "app.js"), []byte("// test"), 0644)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/my-ext", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr != nil {
t.Error("surface should be deleted from DB")
}
if _, err := os.Stat(assetDir); !os.IsNotExist(err) {
t.Error("surface asset directory should be removed")
}
}
func TestSurface_DeleteCore_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("delete core: want 400, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "chat")
if sr == nil {
t.Error("core surface should still exist after rejected delete")
}
}
func TestSurface_DeleteNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("delete nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Install ──────────────────────────
func TestSurface_Install_HappyPath(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "hello-dash", "Hello Dashboard", map[string]string{
"js/app.js": "console.log('hello');",
"css/style.css": "body { color: red; }",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ID != "hello-dash" {
t.Errorf("id: got %q, want %q", resp.ID, "hello-dash")
}
if resp.Source != "extension" {
t.Errorf("source: got %q, want %q", resp.Source, "extension")
}
// Verify in DB
sr, err := h.stores.Surfaces.Get(context.Background(), "hello-dash")
if err != nil || sr == nil {
t.Fatal("surface should exist in DB after install")
}
if sr.Title != "Hello Dashboard" {
t.Errorf("title: got %q, want %q", sr.Title, "Hello Dashboard")
}
// Verify assets extracted
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted")
}
cssPath := filepath.Join(h.tmpDir, "hello-dash", "css", "style.css")
if _, err := os.Stat(cssPath); os.IsNotExist(err) {
t.Error("css/style.css should be extracted")
}
}
func TestSurface_Install_PrefixedArchive(t *testing.T) {
h := setupSurfaceHarness(t)
manifest, _ := json.Marshal(map[string]string{
"id": "hello-dash", "title": "Hello Dashboard", "route": "/s/hello-dash",
})
archive := buildZipRaw(t, map[string]string{
"hello-dash/manifest.json": string(manifest),
"hello-dash/js/app.js": "console.log('hello');",
"hello-dash/css/style.css": "body {}",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install prefixed: want 200, got %d: %s", w.Code, w.Body.String())
}
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted from prefixed archive")
}
}
func TestSurface_Install_MissingManifest(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"js/app.js": "console.log('hello');",
})
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing manifest: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_MissingID(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"manifest.json": `{"title": "No ID"}`,
})
w := h.uploadSurface(h.adminToken, archive, "noid.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing id: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_CoreConflict(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
archive := buildSurfaceZip(t, "chat", "Evil Chat", nil)
w := h.uploadSurface(h.adminToken, archive, "chat.surface")
if w.Code != http.StatusConflict {
t.Errorf("core conflict: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_NoFile(t *testing.T) {
h := setupSurfaceHarness(t)
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", nil)
req.Header.Set("Authorization", "Bearer "+h.adminToken)
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("no file: want 400, got %d", w.Code)
}
}
func TestSurface_Install_InvalidSlugID(t *testing.T) {
h := setupSurfaceHarness(t)
cases := []struct {
id string
desc string
}{
{"../../../etc", "path traversal"},
{"hello world", "spaces"},
{"Hello-Dashboard", "uppercase"},
{"a", "too short"},
{"-leading", "leading hyphen"},
{"trailing-", "trailing hyphen"},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
archive := buildSurfaceZip(t, tc.id, "Bad Surface", nil)
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("bad id %q: want 400, got %d: %s", tc.id, w.Code, w.Body.String())
}
})
}
}
func TestSurface_Install_UpdateExistingExtension(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "my-ext", "My Extension v1", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v1: want 200, got %d: %s", w.Code, w.Body.String())
}
archive = buildSurfaceZip(t, "my-ext", "My Extension v2", nil)
w = h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v2: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Title != "My Extension v2" {
t.Errorf("title: got %q, want %q", sr.Title, "My Extension v2")
}
}
func TestSurface_Install_PreservesEnabledOnReseed(t *testing.T) {
h := setupSurfaceHarness(t)
// Install, then disable
archive := buildSurfaceZip(t, "my-ext", "My Extension", nil)
h.uploadSurface(h.adminToken, archive, "my-ext.surface")
h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
// Re-install — Seed's ON CONFLICT skips enabled column
archive = buildSurfaceZip(t, "my-ext", "My Extension Updated", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("re-install: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Enabled {
t.Error("enabled state should be preserved as false after re-seed")
}
}
// ── Zip Building Helpers ────────────────────
func buildSurfaceZip(t *testing.T, id, title string, extraFiles map[string]string) []byte {
t.Helper()
manifest, _ := json.Marshal(map[string]string{
"id": id, "title": title, "route": "/s/" + id,
})
files := map[string]string{"manifest.json": string(manifest)}
for k, v := range extraFiles {
files[k] = v
}
return buildZipRaw(t, files)
}
func buildZipRaw(t *testing.T, files map[string]string) []byte {
t.Helper()
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
f.Write([]byte(content))
}
zw.Close()
return buf.Bytes()
}