Changeset 0.28.0.10 (#182)

This commit is contained in:
2026-03-13 09:29:04 +00:00
parent 33d76e59ab
commit 9b9e2eb756
9 changed files with 1412 additions and 1128 deletions

View File

@@ -351,7 +351,7 @@ jobs:
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: |
echo "━━━ Postgres Integration Tests ━━━"
go test -v -race -count=1 -p 1 -timeout 8m \
go test -v -race -count=1 -p 1 -timeout 12m \
./store/postgres/... \
./handlers/...
echo "✓ Postgres integration tests complete"

View File

@@ -12,14 +12,15 @@ Extension surfaces are uploaded by admins and served from the
GET /surfaces
```
Returns surfaces the user can access (enabled in registry).
Returns enabled surfaces with minimal nav info (no `source` or `enabled`
fields — these are admin concerns).
```json
{
"surfaces": [
{ "id": "chat", "title": "Chat", "source": "core", "enabled": true },
{ "id": "editor", "title": "Editor", "source": "core", "enabled": true },
{ "id": "hello-dashboard", "title": "Hello Dashboard", "source": "extension", "enabled": true }
{ "id": "chat", "title": "Chat", "route": "/" },
{ "id": "editor", "title": "Editor", "route": "/editor" },
{ "id": "hello-dashboard", "title": "Hello Dashboard", "route": "/s/hello-dashboard" }
]
}
```
@@ -34,7 +35,17 @@ Returns surfaces the user can access (enabled in registry).
GET /admin/surfaces
```
Returns all surfaces including disabled ones.
Returns all surfaces including disabled ones. Each entry is a full
[Surface Registry Object](#surface-registry-object).
```json
{
"surfaces": [
{ "id": "chat", "title": "Chat", "manifest": {...}, "enabled": true, "source": "core", "installed_at": "...", "updated_at": "..." },
{ "id": "hello-dashboard", "title": "Hello Dashboard", "manifest": {...}, "enabled": false, "source": "extension", "installed_at": "...", "updated_at": "..." }
]
}
```
**Auth:** Platform admin.
@@ -46,6 +57,13 @@ GET /admin/surfaces/:id
Returns full surface details including manifest.
**Response:** A single [Surface Registry Object](#surface-registry-object).
**Errors:**
- `404` — surface not found (or store error).
**Auth:** Platform admin.
### Install Surface
```
@@ -53,9 +71,18 @@ POST /admin/surfaces/install
Content-Type: multipart/form-data
```
Field: `archive` — a `.surface` archive (tar.gz containing `manifest.json`
+ static assets). The manifest declares `id`, `title`, `route`, required
data loaders, scripts, and CSS.
Field: `file` — a `.surface` or `.zip` archive (zip containing
`manifest.json` + static assets). The manifest declares `id`, `title`,
`route`, required data loaders, scripts, and CSS.
**Size limit:** 50 MB.
**Manifest validation:** `id` and `title` are required.
**Errors:**
- `400` — no file, wrong extension, too large, invalid zip, missing or
invalid manifest, missing `id`/`title`.
- `409` — surface ID conflicts with an existing core surface.
**Auth:** Platform admin.
@@ -68,6 +95,13 @@ PUT /admin/surfaces/:id/disable
Disabled surfaces redirect to `/` and hide from navigation.
**Undisableable surfaces:** `chat` and `admin` cannot be disabled
(returns `400`).
**Errors:**
- `400` — attempting to disable `chat` or `admin`.
- `404` — surface not found.
**Auth:** Platform admin.
### Delete Surface
@@ -76,10 +110,19 @@ Disabled surfaces redirect to `/` and hide from navigation.
DELETE /admin/surfaces/:id
```
Removes the surface and its static assets. Core surfaces cannot be deleted.
Removes the surface, its DB row, and its extracted static assets.
Core surfaces cannot be deleted.
**Errors:**
- `400` — attempting to delete a core surface.
- `404` — surface not found.
**Auth:** Platform admin.
**Defense-in-depth:** The store layer also enforces
`WHERE source = 'extension'` on delete, so even if the handler check
were bypassed, core surfaces are protected.
## Surface Registry Object
```json
@@ -94,25 +137,45 @@ Removes the surface and its static assets. Core surfaces cannot be deleted.
}
```
## Internal Store Methods
The `SurfaceRegistryStore` interface exposes methods beyond what the
HTTP API uses directly:
- `Seed(ctx, id, title, source, manifest)` — upserts a surface at
startup. Does **not** overwrite the `enabled` flag (admin toggle
survives restarts). Called by the page engine's `SeedSurfaces()`.
- `ListEnabled(ctx) → []string` — returns IDs of all enabled
surfaces. Used by the page engine for nav rendering
(`EnabledSurfaceIDs()`), not exposed via HTTP.
## Page Routes
Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`.
The page engine does a runtime DB lookup — no server restart needed after install.
Static assets are served from `/surfaces/:id/` (nginx location block in
both unified and split deployment).
both unified and split deployment). In unified mode, the Go server
handles this route with path-traversal protection. In split deployment,
nginx serves directly from `/data/surfaces/`.
## Surface Archive Format
A `.surface` file is a tar.gz containing:
A `.surface` file is a **zip** archive containing:
```
manifest.json — required
script.js — optional
style.css — optional
manifest.json — required (must have "id" and "title")
js/ — optional (extracted to /data/surfaces/{id}/js/)
css/ — optional (extracted to /data/surfaces/{id}/css/)
assets/ — optional (icons, images, etc.)
```
The archive may have a single top-level directory prefix (e.g.,
`my-surface/manifest.json`) — the installer strips it when locating
`manifest.json` and when extracting `js/`, `css/`, and `assets/`
directories. Files outside these directories (e.g., `script.js` at
root) are not extracted.
See [EXTENSION-SURFACES.md](../EXTENSION-SURFACES.md) for the full
authoring guide including manifest schema, platform API, and CSS
custom properties.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
"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"
)
// surfaceStore returns the appropriate SurfaceRegistryStore for the
// current test database backend.
func surfaceStore() store.SurfaceRegistryStore {
if database.IsSQLite() {
return sqlite.NewSurfaceRegistryStore()
}
return postgres.NewSurfaceRegistryStore()
}
// ── Store: Seed ─────────────────────────────
func TestSurfaceStore_Seed_Basic(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{"route": "/test", "scripts": []string{"app.js"}}
if err := s.Seed(ctx, "test-surface", "Test Surface", "core", manifest); err != nil {
t.Fatalf("Seed: %v", err)
}
sr, err := s.Get(ctx, "test-surface")
if err != nil || sr == nil {
t.Fatal("Get after Seed: surface should exist")
}
if sr.ID != "test-surface" {
t.Errorf("ID: got %q, want %q", sr.ID, "test-surface")
}
if sr.Title != "Test Surface" {
t.Errorf("Title: got %q, want %q", sr.Title, "Test Surface")
}
if sr.Source != "core" {
t.Errorf("Source: got %q, want %q", sr.Source, "core")
}
if !sr.Enabled {
t.Error("newly seeded surface should be enabled")
}
route, _ := sr.Manifest["route"].(string)
if route != "/test" {
t.Errorf("Manifest route: got %q, want %q", route, "/test")
}
}
func TestSurfaceStore_Seed_PreservesEnabledFlag(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed, then disable
s.Seed(ctx, "my-surface", "v1", "core", map[string]any{"route": "/v1"})
s.SetEnabled(ctx, "my-surface", false)
// Re-seed with updated title and manifest
s.Seed(ctx, "my-surface", "v2", "core", map[string]any{"route": "/v2"})
sr, _ := s.Get(ctx, "my-surface")
if sr == nil {
t.Fatal("surface should exist after re-seed")
}
if sr.Title != "v2" {
t.Errorf("Title should be updated: got %q, want %q", sr.Title, "v2")
}
if sr.Enabled {
t.Error("Enabled should be preserved as false across re-seed")
}
route, _ := sr.Manifest["route"].(string)
if route != "/v2" {
t.Errorf("Manifest should be updated: got %q, want %q", route, "/v2")
}
}
func TestSurfaceStore_Seed_NilManifest(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed with nil manifest — should not panic
err := s.Seed(ctx, "nil-manifest", "Nil Manifest", "core", nil)
if err != nil {
t.Fatalf("Seed with nil manifest: %v", err)
}
sr, _ := s.Get(ctx, "nil-manifest")
if sr == nil {
t.Fatal("surface should exist")
}
}
// ── Store: List ─────────────────────────────
func TestSurfaceStore_List_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
// Should return nil or empty slice, not an error
if surfaces != nil && len(surfaces) != 0 {
t.Errorf("empty DB should return 0 surfaces, got %d", len(surfaces))
}
}
func TestSurfaceStore_List_Order(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed in reverse alphabetical order
s.Seed(ctx, "zebra", "Zebra", "extension", map[string]any{})
s.Seed(ctx, "alpha", "Alpha", "core", map[string]any{})
s.Seed(ctx, "beta", "Beta", "core", map[string]any{})
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 3 {
t.Fatalf("want 3 surfaces, got %d", len(surfaces))
}
// Ordered by source, then title: core first (Alpha, Beta), then extension (Zebra)
if surfaces[0].ID != "alpha" {
t.Errorf("first: got %q, want %q", surfaces[0].ID, "alpha")
}
if surfaces[1].ID != "beta" {
t.Errorf("second: got %q, want %q", surfaces[1].ID, "beta")
}
if surfaces[2].ID != "zebra" {
t.Errorf("third: got %q, want %q", surfaces[2].ID, "zebra")
}
}
func TestSurfaceStore_List_IncludesDisabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "enabled-one", "Enabled", "core", map[string]any{})
s.Seed(ctx, "disabled-one", "Disabled", "extension", map[string]any{})
s.SetEnabled(ctx, "disabled-one", false)
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 2 {
t.Fatalf("List should return all surfaces including disabled, got %d", len(surfaces))
}
}
// ── Store: ListEnabled ──────────────────────
func TestSurfaceStore_ListEnabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
s.Seed(ctx, "editor", "Editor", "core", map[string]any{})
s.Seed(ctx, "my-ext", "My Ext", "extension", map[string]any{})
s.SetEnabled(ctx, "editor", false)
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if len(ids) != 2 {
t.Fatalf("want 2 enabled IDs, got %d: %v", len(ids), ids)
}
// Verify disabled one is excluded
for _, id := range ids {
if id == "editor" {
t.Error("disabled surface 'editor' should not appear in ListEnabled")
}
}
}
func TestSurfaceStore_ListEnabled_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if ids != nil && len(ids) != 0 {
t.Errorf("empty DB should return 0 enabled IDs, got %d", len(ids))
}
}
// ── Store: Get ──────────────────────────────
func TestSurfaceStore_Get_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
sr, err := s.Get(ctx, "nonexistent")
if err != nil {
t.Errorf("Get nonexistent should return (nil, nil), got err: %v", err)
}
if sr != nil {
t.Error("Get nonexistent should return nil surface")
}
}
func TestSurfaceStore_Get_ManifestRoundTrip(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{
"route": "/s/complex",
"scripts": []any{"app.js", "vendor.js"},
"css": []any{"style.css"},
"nested": map[string]any{"key": "value"},
}
s.Seed(ctx, "complex", "Complex", "extension", manifest)
sr, err := s.Get(ctx, "complex")
if err != nil || sr == nil {
t.Fatal("Get should return the surface")
}
// Verify manifest round-trips correctly
got, _ := json.Marshal(sr.Manifest)
want, _ := json.Marshal(manifest)
if string(got) != string(want) {
t.Errorf("Manifest round-trip mismatch:\n got: %s\n want: %s", got, want)
}
}
// ── Store: SetEnabled ───────────────────────
func TestSurfaceStore_SetEnabled_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.SetEnabled(ctx, "nonexistent", true)
if err == nil {
t.Error("SetEnabled on nonexistent surface should return error")
}
}
// ── Store: Delete ───────────────────────────
func TestSurfaceStore_Delete_ExtensionOnly(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed a core surface
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
// Try to delete it at the store level — should fail
err := s.Delete(ctx, "chat")
if err == nil {
t.Error("Delete of core surface should return error at store level")
}
// Verify still exists
sr, _ := s.Get(ctx, "chat")
if sr == nil {
t.Error("core surface should survive Delete attempt")
}
}
func TestSurfaceStore_Delete_Extension(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "my-ext", "My Extension", "extension", map[string]any{})
err := s.Delete(ctx, "my-ext")
if err != nil {
t.Fatalf("Delete extension: %v", err)
}
sr, _ := s.Get(ctx, "my-ext")
if sr != nil {
t.Error("extension surface should be gone after Delete")
}
}
func TestSurfaceStore_Delete_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.Delete(ctx, "nonexistent")
if err == nil {
t.Error("Delete nonexistent should return error")
}
}
// ── Handler + Store Integration: ListEnabled response shape ─────
// This test verifies the exact JSON contract that the frontend depends on.
func TestSurface_ListEnabled_ResponseContract(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
// Parse as raw JSON to verify exact shape
var raw map[string]json.RawMessage
json.Unmarshal(w.Body.Bytes(), &raw)
surfacesJSON, ok := raw["surfaces"]
if !ok {
t.Fatal("response must have 'surfaces' key")
}
var surfaces []map[string]interface{}
json.Unmarshal(surfacesJSON, &surfaces)
if len(surfaces) != 1 {
t.Fatalf("want 1 surface, got %d", len(surfaces))
}
s := surfaces[0]
// Must have exactly id, title, route
expectedKeys := map[string]bool{"id": true, "title": true, "route": true}
for key := range s {
if !expectedKeys[key] {
t.Errorf("unexpected key in response: %q", key)
}
}
for key := range expectedKeys {
if _, ok := s[key]; !ok {
t.Errorf("missing expected key: %q", key)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
@@ -15,6 +16,10 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validSurfaceID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validSurfaceID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct {
stores store.Stores
@@ -197,6 +202,13 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
return
}
// Validate surface ID is a safe slug — prevents path traversal in
// filesystem operations (filepath.Join(surfacesDir, surfaceID)).
if !validSurfaceID.MatchString(surfaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "surface id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" {
@@ -213,21 +225,15 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
if f.FileInfo().IsDir() {
continue
}
// Only extract js/, css/, assets/ directories
name := f.Name
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js")
if idx := strings.Index(name, "/"); idx > 0 {
rest := name[idx+1:]
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") {
name = rest
} else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") {
// Already relative
} else {
continue // Skip non-static files (templates, manifest)
}
// Resolve the relative path, stripping an optional single
// top-level directory prefix from the archive.
relPath := extractableRelPath(f.Name)
if relPath == "" {
continue // Not an extractable static file
}
destPath := filepath.Join(destDir, name)
destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
@@ -267,6 +273,44 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
})
}
// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable static directory (js/, css/, assets/).
// Returns "" if the file should be skipped.
//
// Handles two archive layouts:
//
// Prefixed: my-surface/js/app.js → js/app.js
// Flat: js/app.js → js/app.js
//
// Files not under js/, css/, or assets/ (e.g., manifest.json, script.js
// at root) are skipped.
func extractableRelPath(name string) string {
// Static directory prefixes we extract
staticPrefixes := []string{"js/", "css/", "assets/"}
// Check if already a relative static path (flat archive)
for _, p := range staticPrefixes {
if strings.HasPrefix(name, p) {
return name
}
}
// Check for a single directory prefix to strip
idx := strings.Index(name, "/")
if idx <= 0 {
return "" // No slash, or starts with slash — skip
}
rest := name[idx+1:]
for _, p := range staticPrefixes {
if strings.HasPrefix(rest, p) {
return rest
}
}
return "" // Not under a static directory
}
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {

View File

@@ -0,0 +1,604 @@
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()
}

View File

@@ -119,6 +119,17 @@
if (typeof Theme !== 'undefined') Theme.init();
// Appearance (zoom + font size) must restore on every surface.
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
// v0.28.0: Universal logout — available on every surface.
// Chat/editor/notes load app.js which overrides this with a
// richer version (showConfirm, App.chats cleanup, etc.).
// Extension surfaces get this baseline version.
function handleLogout() {
if (!confirm('Sign out?')) return;
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
if (typeof API !== 'undefined' && API.logout) API.logout();
location.reload();
}
</script>
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
@@ -131,6 +142,39 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}}
{{/* ── v0.28.0: Universal UserMenu hydration ──────────────────
Runs after all surface-specific scripts. Creates and binds the
UserMenu component if no surface script did it already.
Guard: chat surface excluded — it uses UI.toggleUserMenu() with
richer behavior (team admin refresh). Chat migration to UserMenu
component is tracked as tech debt (Pre-1.0 sweep).
*/}}
<script nonce="{{.CSPNonce}}">
(function () {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return;
if ((window.__SURFACE__ || '') === 'chat') return;
var btn = document.getElementById('userMenuBtn');
if (!btn) return;
var menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(window.__USER__ || {});
var user = window.__USER__ || {};
menu.showAdmin(user.role === 'admin');
menu.bind({
onSettings: function () { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: function () { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: function () { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: function () { handleLogout(); },
});
})();
</script>
{{/* ── Debug Log Modal (all surfaces) ───── */}}
<div id="debugModal" class="modal-overlay">
<div class="modal modal-wide">

View File

@@ -13,8 +13,8 @@
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}">
{{/* User menu — must pass dict with ID field, not raw PageData */}}
{{template "user-menu" dict "ID" "ext"}}
{{/* User menu — standard empty prefix, hydrated by base.html universal init */}}
{{template "user-menu" dict "ID" ""}}
<div id="extension-mount" class="extension-mount"></div>
</div>

View File

@@ -17,8 +17,7 @@
const wsId = pageData.WorkspaceID;
const wsName = pageData.WorkspaceName || 'Workspace';
// Wire user menu
_initUserMenu();
// v0.28.0: UserMenu hydration moved to base.html universal init.
// Wire workspace selector
_initWsSelector(wsId);
@@ -32,22 +31,6 @@
_mountEditor(wsId, wsName);
});
// ── User Menu ───────────────────────────
function _initUserMenu() {
if (typeof UserMenu === 'undefined') return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(API.user || window.__USER__);
menu.bind({
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
}
// ── Workspace Selector ──────────────────
function _initWsSelector(currentWsId) {