Changeset 0.28.0.10 (#182)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
1314
docs/ROADMAP.md
1314
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
386
server/handlers/surface_store_test.go
Normal file
386
server/handlers/surface_store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
604
server/handlers/surfaces_test.go
Normal file
604
server/handlers/surfaces_test.go
Normal 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()
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user