606 lines
18 KiB
Go
606 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)
|
|
}
|
|
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()
|
|
}
|