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) } userCache := middleware.NewUserStatusCache() 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, stores.Users, userCache)) userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces) // Admin endpoints surfaceAdm := NewSurfaceHandler(stores, tmpDir) adminGroup := api.Group("/admin") adminGroup.Use(middleware.Auth(cfg, stores.Users, userCache)) 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() }