Feat v0.7.6 code hygiene #60

Merged
xcaliber merged 1 commits from feat/v0.7.6-code-hygiene into main 2026-04-02 17:39:41 +00:00
22 changed files with 850 additions and 144 deletions

View File

@@ -2,6 +2,32 @@
All notable changes to Armature are documented here.
## v0.7.6 — Code Hygiene + Test Coverage
**Critical Fixes**
- Removed `channels` from `allowedViews` in `db_module.go``ext_view_channels` does not exist; `db.view("channels")` would crash
- Fixed 5 dead API routes in `workflow.html` — rewired to public workflow API (`/api/v1/public/workflows/`)
- Fixed `RenderWorkflow` handler to pass route `:id` param as entry token (was reading unset `channel_id`)
**Dead Code Removal**
- Deleted `SeedTestChannel()` from `database/testhelper.go` (inserted into nonexistent channels table)
- Removed `RunContext.ChannelID` from `sandbox/runner.go` (vestigial, unused)
- Removed `webhook.Payload.ChannelID` field (channels no longer exist — breaking webhook JSON change)
- Fixed stale comments in `storage.go`, `prometheus.go`, `workflow_module.go` referencing dead `/channels` paths
**Migration Hygiene**
- Added SQLite placeholder `013_cluster_registry.sql` (PG-only migration, aligns numbering)
- Renumbered SQLite `013_test_runner_type.sql``014` to match PG (compat rename in `migrate.go`)
- Documented missing migration 008 in both 009 files (merged into 007 during pre-1.0 consolidation)
**Test Coverage**
- 82 new workflow routing tests (`routing_test.go`): `ResolveNextStage`, `ResolveStageByName`, `ParseStageConfig`, `evaluateCondition` with all 10 operators
- 10 new middleware tests (`permissions_test.go`): `RequirePermission`, `RequireAdmin`, permission caching, `RateLimiter` (allow/deny/fail-open)
**Bug Fixes**
- Removed dead Admin "Storage" tab from System category (backend endpoint preserved for future Monitoring use)
- Fixed backup download: `sw.auth.token()``sw.auth._getToken()` — both "Download Backup" and server backup download now work
## v0.7.5 — Headless E2E + CI Gate
**CI Gating Redesign**

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap
## Current: v0.7.5Headless E2E + CI Gate
## Current: v0.7.6Code Hygiene + Test Coverage
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else
@@ -200,33 +200,40 @@ a test coverage gap in the store layer. Fix before v0.8.x kernel expansion.
| Step | Status | Description |
|------|--------|-------------|
| Remove channels from allowedViews | | `db_module.go``ext_view_channels` does not exist. `db.view("channels")` crashes. |
| Fix workflow.html dead routes | | Template calls `/api/v1/channels/{id}/workflow/*` — routes moved to `/api/v1/public/workflows/`. Update template to match. |
| Remove channels from allowedViews | done | `db_module.go``ext_view_channels` does not exist. `db.view("channels")` crashes. |
| Fix workflow.html dead routes | done | Template calls `/api/v1/channels/{id}/workflow/*` — routes moved to `/api/v1/public/workflows/`. Update template to match. Also fixed `RenderWorkflow` handler to use route param as entry token. |
**Dead Code Removal**
| Step | Status | Description |
|------|--------|-------------|
| Delete SeedTestChannel() | | `database/testhelper.go` — inserts into nonexistent channels table. |
| Remove RunContext.ChannelID | | `sandbox/runner.go` — vestigial field, unused by any module. |
| Remove webhook.ChannelID | | `webhook/webhook.go` — vestigial struct field from chat era. |
| Fix stale comments | | `storage.go` key format, `prometheus.go` route pattern — reference dead `/channels` paths. |
| Delete SeedTestChannel() | done | `database/testhelper.go` — inserts into nonexistent channels table. |
| Remove RunContext.ChannelID | done | `sandbox/runner.go` — vestigial field, unused by any module. |
| Remove webhook.ChannelID | done | `webhook/webhook.go` — vestigial struct field from chat era. |
| Fix stale comments | done | `storage.go` key format, `prometheus.go` route pattern, `workflow_module.go` param name — reference dead `/channels` paths. |
**Migration Hygiene**
| Step | Status | Description |
|------|--------|-------------|
| Align SQLite migration numbering | | SQLite 013 = test_runner_type, PG 014 = same. Add placeholder `013_cluster_registry.sql` to SQLite (comment-only, explains PG-only). |
| Document missing 008 | | Add comment in both 009 files explaining the gap (merged/removed). |
| Align SQLite migration numbering | done | SQLite 013 placeholder + renumber test_runner_type to 014. Compat rename in migrate.go. |
| Document missing 008 | done | Comment in both 009 files explaining the gap (merged into 007). |
**Test Gap Closure**
| Step | Status | Description |
|------|--------|-------------|
| store/ unit tests | | Direct tests for PG + SQLite store implementations. Priority: packages, ext_data, workflows. Target: ≥30% SLOC ratio (from 2%). |
| workflow/ engine unit tests | | Direct tests for `engine.go`, `routing.go`, `automated.go`. Currently 0% — tested only through handler integration. |
| middleware/ unit tests | | Auth middleware, rate limit, permissions. Currently 8.9%. |
| InstallPackage decomposition | | 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. Each independently testable. |
| store/ unit tests | | Deferred to v0.7.8. Direct tests for PG + SQLite store implementations. Requires live DB. Target: ≥30% SLOC ratio. |
| workflow/ routing unit tests | done | 82 tests: ResolveNextStage, ResolveStageByName, ParseStageConfig, evaluateCondition (all operators). |
| middleware/ unit tests | done | 10 tests: RequirePermission, RequireAdmin, permission caching, RateLimiter (allow/deny/fail-open). |
| InstallPackage decomposition | | Deferred to v0.7.8. 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. |
**Bug Fixes**
| Step | Status | Description |
|------|--------|-------------|
| Remove Admin Storage tab | done | Dead weight under System — backend endpoint preserved for future Monitoring use. |
| Fix backup download | done | `sw.auth.token()``sw.auth._getToken()`. Both "Download Backup" and server backup download buttons now work. |
### v0.7.7 — API Tokens + Extension Permissions
@@ -261,6 +268,16 @@ sidecar auth (v0.10.x).
| `gate_permission` manifest field | | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
| `req["permissions"]` in request dict | | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
### v0.7.8 — Deferred Test Coverage
Remaining test gap items deferred from v0.7.6.
| Step | Status | Description |
|------|--------|-------------|
| store/ unit tests | | Direct tests for PG + SQLite store implementations. Priority: packages, ext_data, workflows. Target: ≥30% SLOC ratio. |
| InstallPackage decomposition | | 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. Each independently testable. |
| workflow/ engine integration tests | | Engine lifecycle tests with mock stores (Start, Advance, Cancel, signoff gate). |
---
## Planned

View File

@@ -1 +1 @@
0.7.5
0.7.6

View File

@@ -74,6 +74,12 @@ func Migrate() error {
}
}
// v0.7.6: SQLite 013_test_runner_type.sql was renumbered to 014 to
// align with PG migration numbering. Mark as applied if the old name exists.
if !IsPostgres() {
DB.Exec("UPDATE schema_migrations SET version = '014_test_runner_type.sql' WHERE version = '013_test_runner_type.sql'")
}
// Apply pending migrations.
applied := 0
for _, file := range files {

View File

@@ -1,6 +1,7 @@
-- ==========================================
-- Armature — 009 Multi-Replica HA
-- ==========================================
-- Note: migration 008 was merged into 007 during pre-1.0 schema consolidation.
CREATE TABLE IF NOT EXISTS ws_tickets (
id TEXT PRIMARY KEY,

View File

@@ -1,6 +1,7 @@
-- ==========================================
-- Armature — 009 Multi-Replica HA (SQLite)
-- ==========================================
-- Note: migration 008 was merged into 007 during pre-1.0 schema consolidation.
CREATE TABLE IF NOT EXISTS ws_tickets (
id TEXT PRIMARY KEY,

View File

@@ -0,0 +1,7 @@
-- 013_cluster_registry.sql — placeholder
-- Postgres-only migration: creates UNLOGGED node_registry table for
-- cluster self-assembly (v0.6.0). SQLite deployments are single-node
-- and do not use the cluster registry. This placeholder aligns the
-- migration numbering between dialects.
--
-- See postgres/013_cluster_registry.sql for the actual schema.

View File

@@ -1,4 +1,4 @@
-- 013_test_runner_type.sql — v0.7.1
-- 014_test_runner_type.sql — v0.7.1
-- Adds 'test-runner' to the packages.type CHECK constraint.
-- SQLite doesn't support ALTER CHECK — must recreate the table.

View File

@@ -427,25 +427,6 @@ func SeedAdminsGroupMember(t *testing.T, userID string) {
}
}
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
// SeedTestTeam creates a test team and returns the team ID.
func SeedTestTeam(t *testing.T, name, createdBy string) string {
t.Helper()

View File

@@ -0,0 +1,255 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"armature/auth"
"armature/store"
)
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// mockRateLimitStore implements store.RateLimitStore for testing.
type mockRateLimitStore struct {
allowed bool
err error
}
func (m *mockRateLimitStore) Allow(_ context.Context, _ string, _ float64, _ int) (bool, error) {
return m.allowed, m.err
}
func (m *mockRateLimitStore) Cleanup(_ context.Context, _ time.Duration) error { return nil }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func init() { gin.SetMode(gin.TestMode) }
// newEngine builds a test engine that injects user_id and optional pre-cached
// permissions before the middleware under test runs. This avoids needing to
// mock auth.ResolvePermissions (a package-level function).
func newEngine(userID string, perms map[string]bool, mw gin.HandlerFunc) *gin.Engine {
e := gin.New()
e.Use(func(c *gin.Context) {
if userID != "" {
c.Set("user_id", userID)
}
if perms != nil {
c.Set(permCacheKey, perms)
}
c.Next()
})
e.Use(mw)
e.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
return e
}
func bodyMap(w *httptest.ResponseRecorder) map[string]interface{} {
var m map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &m)
return m
}
// ---------------------------------------------------------------------------
// RequirePermission tests
// ---------------------------------------------------------------------------
func TestRequirePermission_Allowed(t *testing.T) {
s := store.Stores{} // not used — perms are pre-cached
perms := map[string]bool{"notes.read": true}
engine := newEngine("user-1", perms, RequirePermission("notes.read", s))
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
body := bodyMap(w)
if body["ok"] != true {
t.Errorf("expected ok=true, got %v", body)
}
}
func TestRequirePermission_Denied(t *testing.T) {
s := store.Stores{}
perms := map[string]bool{"notes.read": true} // no "notes.write"
engine := newEngine("user-1", perms, RequirePermission("notes.write", s))
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
body := bodyMap(w)
errMsg, _ := body["error"].(string)
if errMsg != "permission required: notes.write" {
t.Errorf("unexpected error message: %q", errMsg)
}
}
func TestRequirePermission_CachingAcrossChain(t *testing.T) {
// Two RequirePermission middlewares in the same chain should share the
// cached permission map — the second must NOT trigger a fresh resolve.
s := store.Stores{}
perms := map[string]bool{"a.read": true, "b.read": true}
e := gin.New()
e.Use(func(c *gin.Context) {
c.Set("user_id", "user-1")
c.Set(permCacheKey, perms)
c.Next()
})
e.Use(RequirePermission("a.read", s))
e.Use(RequirePermission("b.read", s))
e.GET("/test", func(c *gin.Context) {
// Verify the cached permissions are still present and correct.
resolved := GetResolvedPermissions(c)
if resolved == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cache missing"})
return
}
if !resolved["a.read"] || !resolved["b.read"] {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cache incomplete"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
e.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
}
// ---------------------------------------------------------------------------
// GetResolvedPermissions tests
// ---------------------------------------------------------------------------
func TestGetResolvedPermissions_NilWhenNotSet(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
if got := GetResolvedPermissions(c); got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestGetResolvedPermissions_ReturnsCached(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
want := map[string]bool{"x": true}
c.Set(permCacheKey, want)
got := GetResolvedPermissions(c)
if got == nil || !got["x"] {
t.Errorf("expected cached perms, got %v", got)
}
}
// ---------------------------------------------------------------------------
// RequireAdmin tests
// ---------------------------------------------------------------------------
func TestRequireAdmin_Allowed(t *testing.T) {
s := store.Stores{}
perms := map[string]bool{auth.PermSurfaceAdminAccess: true}
engine := newEngine("admin-1", perms, RequireAdmin(s))
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestRequireAdmin_Denied(t *testing.T) {
s := store.Stores{}
perms := map[string]bool{"notes.read": true} // no admin perm
engine := newEngine("user-1", perms, RequireAdmin(s))
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
body := bodyMap(w)
errMsg, _ := body["error"].(string)
if errMsg != "admin access required" {
t.Errorf("unexpected error message: %q", errMsg)
}
}
// ---------------------------------------------------------------------------
// RateLimiter.Limit tests
// ---------------------------------------------------------------------------
func TestRateLimiter_Allowed(t *testing.T) {
rl := NewRateLimiter(&mockRateLimitStore{allowed: true}, 10, 20)
engine := newEngine("", nil, rl.Limit())
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestRateLimiter_Exceeded(t *testing.T) {
rl := NewRateLimiter(&mockRateLimitStore{allowed: false}, 10, 20)
engine := newEngine("", nil, rl.Limit())
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429, got %d", w.Code)
}
if ra := w.Header().Get("Retry-After"); ra != "1" {
t.Errorf("expected Retry-After=1, got %q", ra)
}
body := bodyMap(w)
errMsg, _ := body["error"].(string)
if errMsg != "rate limit exceeded" {
t.Errorf("unexpected error message: %q", errMsg)
}
}
func TestRateLimiter_StoreError_FailOpen(t *testing.T) {
rl := NewRateLimiter(&mockRateLimitStore{err: errors.New("db down")}, 10, 20)
engine := newEngine("", nil, rl.Limit())
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 (fail-open), got %d", w.Code)
}
}

View File

@@ -11,7 +11,7 @@ import (
// Prometheus returns a Gin middleware that records HTTP request metrics.
// Uses c.FullPath() (Gin's registered route pattern, e.g.
// "/api/v1/channels/:id") to avoid label cardinality explosion from
// "/api/v1/workflows/:id") to avoid label cardinality explosion from
// path parameters.
func Prometheus() gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -668,10 +668,10 @@ type WorkflowLandingPageData struct {
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
channelID := c.GetString("channel_id")
// :id is the public entry token (from /w/:id)
channelID := c.Param("id")
sessionID := c.GetString("session_id")
// Load channel metadata
var title, description string
if title == "" {
title = "Workflow"

View File

@@ -474,7 +474,7 @@
}
try {
var resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/form-submit', {
var resp = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: data }),
@@ -561,11 +561,8 @@
addMessage('user', text, '{{.Data.SessionName}}');
try {
var resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: CHAN_ID, content: text, stream: false }),
});
// TODO(v0.8+): form_chat completions endpoint not yet implemented
var resp = { ok: false, statusText: 'Chat mode not yet available', json: function() { return Promise.resolve({ error: 'Chat mode not yet available' }); } };
if (resp.ok) {
var data = await resp.json();
if (data.content) {
@@ -627,7 +624,7 @@
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
try {
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + CHAN_ID, {
headers: { 'Content-Type': 'application/json' },
});
if (resp.ok) {
@@ -684,7 +681,7 @@
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
this.disabled = true;
try {
var r = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/advance', {
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: {} }),
});
@@ -705,9 +702,9 @@
if (!reason) return;
this.disabled = true;
try {
var r = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/reject', {
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason }),
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
});
if (r.ok) {
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';

View File

@@ -15,8 +15,8 @@
// rows = db.view("users", filters={"id": "abc"})
//
// All table access is scoped to ext_{package_id}_{table_name}.
// Platform views (ext_view_users, ext_view_channels) are accessible
// via db.view() and are read-only regardless of permission level.
// Platform views (ext_view_users) are accessible via db.view()
// and are read-only regardless of permission level.
//
// Queries are parameterized — no raw SQL is ever accepted from scripts.
// Dialect differences (placeholder style) are handled internally.
@@ -44,8 +44,7 @@ type DBModuleConfig struct {
// allowedViews is the set of platform views extensions may query via db.view().
var allowedViews = map[string]bool{
"users": true,
"channels": true,
"users": true,
}
// BuildDBModule creates the "db" starlark module for an extension.
@@ -435,7 +434,7 @@ func dbView(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *sta
}
if !allowedViews[viewName] {
return nil, fmt.Errorf("db.view: unknown view %q (allowed: users, channels)", viewName)
return nil, fmt.Errorf("db.view: unknown view %q (allowed: users)", viewName)
}
physView := "ext_view_" + viewName

View File

@@ -61,9 +61,6 @@ type RunContext struct {
// UserID is the acting user for provider resolution (BYOK chain).
UserID string
// ChannelID is the context identifier, if any.
ChannelID string
// TeamID is the team context for settings cascade resolution.
// When set, team-scoped package settings are included in the cascade.
TeamID string

View File

@@ -187,4 +187,4 @@ func goValToStarlark(v interface{}) starlark.Value {
}
// workflowRoute routes the workflow to a named stage.
// Starlark: workflow.route(channel_id, target_stage, reason)
// Starlark: workflow.route(instance_id, target_stage, reason)

View File

@@ -24,7 +24,7 @@ var (
//
// Keys are slash-delimited paths relative to the storage root:
//
// files/{channel_id}/{file_id}_{filename}
// ext/{package_id}/{file_id}_{filename}
//
// Implementations must create intermediate directories as needed.
type ObjectStore interface {
@@ -43,8 +43,8 @@ type ObjectStore interface {
Delete(ctx context.Context, key string) error
// DeletePrefix removes all objects under the given prefix.
// Used for channel deletion (bulk cleanup).
// Example: DeletePrefix(ctx, "files/{channel_id}/")
// Used for bulk cleanup (e.g. extension uninstall).
// Example: DeletePrefix(ctx, "ext/{package_id}/")
DeletePrefix(ctx context.Context, prefix string) error
// Exists checks if an object exists at key without reading it.

View File

@@ -29,7 +29,6 @@ type Payload struct {
RunID string `json:"run_id,omitempty"`
TaskName string `json:"task_name,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Status string `json:"status"`
CompletedAt time.Time `json:"completed_at"`
Output string `json:"output,omitempty"` // last assistant message or relay payload

View File

@@ -0,0 +1,497 @@
package workflow
import (
"encoding/json"
"testing"
"armature/models"
)
// ── helpers ────────────────────────────────────────────
func mkStages(names ...string) []models.WorkflowStage {
out := make([]models.WorkflowStage, len(names))
for i, n := range names {
out[i] = models.WorkflowStage{
ID: n,
WorkflowID: "wf-test",
Ordinal: i,
Name: n,
}
}
return out
}
func withBranchRules(stages []models.WorkflowStage, idx int, rules []Condition) []models.WorkflowStage {
b, _ := json.Marshal(rules)
stages[idx].BranchRules = b
return stages
}
func mustJSON(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// ── ResolveNextStage ───────────────────────────────────
func TestResolveNextStage_NoRules(t *testing.T) {
stages := mkStages("intake", "review", "done")
got, err := ResolveNextStage(stages, 0, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("expected ordinal 1, got %d", got)
}
}
func TestResolveNextStage_EmptyRulesArray(t *testing.T) {
stages := mkStages("intake", "review", "done")
stages[0].BranchRules = json.RawMessage(`[]`)
got, err := ResolveNextStage(stages, 0, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("expected ordinal 1, got %d", got)
}
}
func TestResolveNextStage_SingleMatchingRule(t *testing.T) {
stages := mkStages("intake", "review", "escalation", "done")
rules := []Condition{{Field: "priority", Op: "eq", Value: "high", TargetStage: "escalation"}}
withBranchRules(stages, 0, rules)
data := mustJSON(map[string]any{"priority": "high"})
got, err := ResolveNextStage(stages, 0, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 2 {
t.Errorf("expected ordinal 2 (escalation), got %d", got)
}
}
func TestResolveNextStage_FirstMatchWins(t *testing.T) {
stages := mkStages("intake", "fast-track", "escalation", "done")
rules := []Condition{
{Field: "priority", Op: "eq", Value: "high", TargetStage: "fast-track"},
{Field: "priority", Op: "eq", Value: "high", TargetStage: "escalation"},
}
withBranchRules(stages, 0, rules)
data := mustJSON(map[string]any{"priority": "high"})
got, err := ResolveNextStage(stages, 0, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("expected ordinal 1 (fast-track), got %d", got)
}
}
func TestResolveNextStage_NoMatchFallback(t *testing.T) {
stages := mkStages("intake", "review", "done")
rules := []Condition{{Field: "priority", Op: "eq", Value: "critical", TargetStage: "done"}}
withBranchRules(stages, 0, rules)
data := mustJSON(map[string]any{"priority": "low"})
got, err := ResolveNextStage(stages, 0, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("expected fallback ordinal 1, got %d", got)
}
}
func TestResolveNextStage_OutOfBounds(t *testing.T) {
stages := mkStages("intake", "done")
tests := []struct {
name string
current int
want int
}{
{"negative", -1, 0},
{"beyond end", 5, 6},
{"equal to len", 2, 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveNextStage(stages, tt.current, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("expected %d, got %d", tt.want, got)
}
})
}
}
func TestResolveNextStage_NilAndEmptyStageData(t *testing.T) {
stages := mkStages("intake", "review", "done")
rules := []Condition{{Field: "status", Op: "exists", TargetStage: "done"}}
withBranchRules(stages, 0, rules)
// nil stageData — "exists" should be false, fallback to ordinal+1
got, err := ResolveNextStage(stages, 0, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("nil data: expected 1, got %d", got)
}
// empty object — "exists" should still be false
got, err = ResolveNextStage(stages, 0, json.RawMessage(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 1 {
t.Errorf("empty data: expected 1, got %d", got)
}
}
func TestResolveNextStage_TerminalStage(t *testing.T) {
stages := mkStages("intake", "review", "done")
// At last stage with no rules, ordinal+1 == len(stages) which is past end
got, err := ResolveNextStage(stages, 2, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 3 {
t.Errorf("expected terminal ordinal 3, got %d", got)
}
}
func TestResolveNextStage_TargetByOrdinalString(t *testing.T) {
stages := mkStages("intake", "review", "done")
rules := []Condition{{Field: "skip", Op: "eq", Value: "yes", TargetStage: "2"}}
withBranchRules(stages, 0, rules)
data := mustJSON(map[string]any{"skip": "yes"})
got, err := ResolveNextStage(stages, 0, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 2 {
t.Errorf("expected ordinal 2, got %d", got)
}
}
func TestResolveNextStage_InvalidTarget(t *testing.T) {
stages := mkStages("intake", "review", "done")
rules := []Condition{{Field: "go", Op: "eq", Value: "yes", TargetStage: "nonexistent"}}
withBranchRules(stages, 0, rules)
data := mustJSON(map[string]any{"go": "yes"})
_, err := ResolveNextStage(stages, 0, data)
if err == nil {
t.Fatalf("expected error for invalid target, got nil")
}
}
// ── ResolveStageByName ─────────────────────────────────
func TestResolveStageByName(t *testing.T) {
stages := mkStages("Intake", "Review", "Done")
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"exact match", "Intake", 0, false},
{"case insensitive lower", "review", 1, false},
{"case insensitive upper", "DONE", 2, false},
{"mixed case", "rEvIeW", 1, false},
{"leading whitespace", " Intake", 0, false},
{"trailing whitespace", "Done ", 2, false},
{"both whitespace", " Review ", 1, false},
{"not found", "nonexistent", 0, true},
{"empty string", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveStageByName(stages, tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("expected ordinal %d, got %d", tt.want, got)
}
})
}
}
// ── ParseStageConfig ───────────────────────────────────
func TestParseStageConfig(t *testing.T) {
tests := []struct {
name string
input json.RawMessage
checkField string // which field to validate
wantStr string
wantInt int
}{
{
name: "valid with auto_assign",
input: json.RawMessage(`{"auto_assign":"team-lead","required_role":"manager"}`),
checkField: "auto_assign",
wantStr: "team-lead",
},
{
name: "valid with required_role",
input: json.RawMessage(`{"required_role":"admin"}`),
checkField: "required_role",
wantStr: "admin",
},
{
name: "valid with validation",
input: json.RawMessage(`{"validation":{"required_approvals":3}}`),
checkField: "validation_approvals",
wantInt: 3,
},
{
name: "empty input",
input: nil,
checkField: "auto_assign",
wantStr: "",
},
{
name: "null string",
input: json.RawMessage(`null`),
checkField: "auto_assign",
wantStr: "",
},
{
name: "empty object",
input: json.RawMessage(`{}`),
checkField: "auto_assign",
wantStr: "",
},
{
name: "invalid json",
input: json.RawMessage(`{bad json`),
checkField: "auto_assign",
wantStr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sc := ParseStageConfig(tt.input)
switch tt.checkField {
case "auto_assign":
if sc.AutoAssign != tt.wantStr {
t.Errorf("AutoAssign: expected %q, got %q", tt.wantStr, sc.AutoAssign)
}
case "required_role":
if sc.RequiredRole != tt.wantStr {
t.Errorf("RequiredRole: expected %q, got %q", tt.wantStr, sc.RequiredRole)
}
case "validation_approvals":
if sc.Validation == nil {
t.Fatalf("expected Validation to be set")
}
if sc.Validation.RequiredApprovals != tt.wantInt {
t.Errorf("RequiredApprovals: expected %d, got %d", tt.wantInt, sc.Validation.RequiredApprovals)
}
}
})
}
}
// ── evaluateCondition ──────────────────────────────────
func TestEvaluateCondition_Exists(t *testing.T) {
data := map[string]any{"name": "alice", "age": 30}
tests := []struct {
name string
cond Condition
want bool
}{
{"exists present", Condition{Field: "name", Op: "exists"}, true},
{"exists absent", Condition{Field: "missing", Op: "exists"}, false},
{"not_exists absent", Condition{Field: "missing", Op: "not_exists"}, true},
{"not_exists present", Condition{Field: "name", Op: "not_exists"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestEvaluateCondition_EqNeq(t *testing.T) {
data := map[string]any{"status": "open", "count": float64(5)}
tests := []struct {
name string
cond Condition
want bool
}{
{"eq string match", Condition{Field: "status", Op: "eq", Value: "open"}, true},
{"eq string no match", Condition{Field: "status", Op: "eq", Value: "closed"}, false},
{"neq string match", Condition{Field: "status", Op: "neq", Value: "closed"}, true},
{"neq string no match", Condition{Field: "status", Op: "neq", Value: "open"}, false},
{"eq numeric match", Condition{Field: "count", Op: "eq", Value: float64(5)}, true},
{"eq numeric no match", Condition{Field: "count", Op: "eq", Value: float64(10)}, false},
{"neq numeric", Condition{Field: "count", Op: "neq", Value: float64(3)}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestEvaluateCondition_NumericComparisons(t *testing.T) {
data := map[string]any{"score": float64(75)}
tests := []struct {
name string
cond Condition
want bool
}{
{"gt true", Condition{Field: "score", Op: "gt", Value: float64(50)}, true},
{"gt false equal", Condition{Field: "score", Op: "gt", Value: float64(75)}, false},
{"gt false less", Condition{Field: "score", Op: "gt", Value: float64(100)}, false},
{"lt true", Condition{Field: "score", Op: "lt", Value: float64(100)}, true},
{"lt false equal", Condition{Field: "score", Op: "lt", Value: float64(75)}, false},
{"lt false greater", Condition{Field: "score", Op: "lt", Value: float64(50)}, false},
{"gte true greater", Condition{Field: "score", Op: "gte", Value: float64(50)}, true},
{"gte true equal", Condition{Field: "score", Op: "gte", Value: float64(75)}, true},
{"gte false", Condition{Field: "score", Op: "gte", Value: float64(100)}, false},
{"lte true less", Condition{Field: "score", Op: "lte", Value: float64(100)}, true},
{"lte true equal", Condition{Field: "score", Op: "lte", Value: float64(75)}, true},
{"lte false", Condition{Field: "score", Op: "lte", Value: float64(50)}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestEvaluateCondition_In(t *testing.T) {
data := map[string]any{"role": "admin"}
tests := []struct {
name string
cond Condition
want bool
}{
{"in list match", Condition{Field: "role", Op: "in", Value: []any{"admin", "manager"}}, true},
{"in list no match", Condition{Field: "role", Op: "in", Value: []any{"user", "guest"}}, false},
{"in empty list", Condition{Field: "role", Op: "in", Value: []any{}}, false},
{"in non-list value", Condition{Field: "role", Op: "in", Value: "admin"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestEvaluateCondition_Contains(t *testing.T) {
data := map[string]any{"description": "urgent: fix production bug"}
tests := []struct {
name string
cond Condition
want bool
}{
{"contains match", Condition{Field: "description", Op: "contains", Value: "urgent"}, true},
{"contains substring", Condition{Field: "description", Op: "contains", Value: "production"}, true},
{"contains no match", Condition{Field: "description", Op: "contains", Value: "minor"}, false},
{"contains empty target", Condition{Field: "description", Op: "contains", Value: ""}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestEvaluateCondition_MissingField(t *testing.T) {
data := map[string]any{"present": "yes"}
ops := []string{"eq", "neq", "gt", "lt", "gte", "lte", "in", "contains"}
for _, op := range ops {
t.Run(op, func(t *testing.T) {
cond := Condition{Field: "absent", Op: op, Value: "something"}
got := evaluateCondition(cond, data)
if got != false {
t.Errorf("op %q on missing field: expected false, got true", op)
}
})
}
}
func TestEvaluateCondition_UnknownOperator(t *testing.T) {
data := map[string]any{"field": "value"}
cond := Condition{Field: "field", Op: "regex", Value: ".*"}
got := evaluateCondition(cond, data)
if got != false {
t.Errorf("unknown operator: expected false, got true")
}
}
func TestEvaluateCondition_EmptyData(t *testing.T) {
data := map[string]any{}
cond := Condition{Field: "anything", Op: "eq", Value: "test"}
got := evaluateCondition(cond, data)
if got != false {
t.Errorf("empty data: expected false, got true")
}
}
func TestEvaluateCondition_NumericStringComparison(t *testing.T) {
// JSON unmarshalling produces float64 for numbers; test string numeric values too
data := map[string]any{"amount": "100.5"}
tests := []struct {
name string
cond Condition
want bool
}{
{"gt string num", Condition{Field: "amount", Op: "gt", Value: float64(50)}, true},
{"lt string num", Condition{Field: "amount", Op: "lt", Value: float64(200)}, true},
{"eq string num", Condition{Field: "amount", Op: "eq", Value: "100.5"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluateCondition(tt.cond, data)
if got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}

View File

@@ -30,12 +30,12 @@ export default function BackupSection() {
sw.toast('Backup created', 'success');
load();
} else {
// Stream download
// Stream download — use _getToken() for raw bearer token
const url = '/api/v1/admin/backup';
const token = sw.auth.token();
const token = sw.auth._getToken();
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
headers: token ? { 'Authorization': 'Bearer ' + token } : {},
});
if (!resp.ok) throw new Error('Backup failed');
const blob = await resp.blob();
@@ -54,9 +54,9 @@ export default function BackupSection() {
}
async function downloadBackup(name) {
const token = sw.auth.token();
const token = sw.auth._getToken();
const resp = await fetch(sw.api.admin.backup.download(name), {
headers: { 'Authorization': 'Bearer ' + token },
headers: token ? { 'Authorization': 'Bearer ' + token } : {},
});
if (!resp.ok) { sw.toast('Download failed', 'error'); return; }
const blob = await resp.blob();

View File

@@ -20,14 +20,14 @@ import { DialogStack } from '../../shell/dialog-stack.js';
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
workflows: ['workflows'],
system: ['settings', 'storage', 'packages', 'connections', 'broadcast', 'backup'],
system: ['settings', 'packages', 'connections', 'broadcast', 'backup'],
monitoring: ['health', 'audit'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
workflows: 'Workflows',
settings: 'Settings', storage: 'Storage', packages: 'Packages',
settings: 'Settings', packages: 'Packages',
connections: 'Connections', broadcast: 'Broadcast',
backup: 'Backup',
health: 'Health', audit: 'Audit',
@@ -63,7 +63,6 @@ const sectionModules = {
groups: () => import(`./groups.js${_v}`),
workflows: () => import(`./workflows.js${_v}`),
settings: () => import(`./settings.js${_v}`),
storage: () => import(`./storage.js${_v}`),
packages: () => import(`./packages.js${_v}`),
connections: () => import(`./connections.js${_v}`),
broadcast: () => import(`./broadcast.js${_v}`),

View File

@@ -1,76 +0,0 @@
/**
* Admin > System > Storage
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function StorageSection() {
const [status, setStatus] = useState(null);
const [orphans, setOrphans] = useState(null);
const [extraction, setExtraction] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [s, o, e] = await Promise.all([
sw.api.admin.storage.status(),
sw.api.admin.storage.orphans(),
sw.api.admin.storage.extraction().catch(() => null),
]);
setStatus(s);
setOrphans(o);
setExtraction(e);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function cleanup() {
const ok = await sw.confirm('Remove orphaned blobs?');
if (!ok) return;
try {
await sw.api.admin.storage.cleanup();
sw.toast('Cleanup complete', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function formatBytes(b) {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
return (b / 1073741824).toFixed(2) + ' GB';
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${status && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${status.backend || '\u2014'}</div><div class="stat-label">Backend</div></div>
<div class="stat-card"><div class="stat-value"><span class="badge ${status.healthy ? 'badge-active' : 'badge-inactive'}">${status.healthy ? 'Healthy' : 'Unhealthy'}</span></div><div class="stat-label">Status</div></div>
<div class="stat-card"><div class="stat-value">${status.file_count ?? '\u2014'}</div><div class="stat-label">Files</div></div>
<div class="stat-card"><div class="stat-value">${formatBytes(status.total_bytes)}</div><div class="stat-label">Total Size</div></div>
</div>
`}
${orphans && html`
<div class="settings-section" style="margin-bottom:16px;">
<h4>Orphaned Blobs</h4>
<p class="text-muted" style="font-size:12px;">${orphans.count || 0} orphans found</p>
${(orphans.count || 0) > 0 && html`<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${cleanup}>Clean Up</button>`}
</div>
`}
${extraction && html`
<div class="settings-section">
<h4>Extraction Queue</h4>
<pre class="code-block" style="font-size:12px;max-height:200px;overflow:auto;">${JSON.stringify(extraction, null, 2)}</pre>
</div>
`}
</div>
`;
}