From 85d5e3cc1377cfdc683daca722228b7412e7f7b7 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sat, 14 Mar 2026 19:36:33 +0000 Subject: [PATCH] Changeset 0.28.4 (#190) --- .gitignore | 1 + CHANGELOG.md | 103 +++ VERSION | 2 +- docs/ROADMAP.md | 50 +- hello-dashboard.surface | Bin 3152 -> 0 bytes server/handlers/admin.go | 21 +- server/handlers/extension_test.go | 5 +- server/handlers/integration_test.go | 5 +- server/handlers/notification_test.go | 3 +- server/handlers/profile_test.go | 13 +- server/handlers/projects_test.go | 5 +- server/handlers/route_test.go | 11 +- server/handlers/surfaces_test.go | 5 +- server/handlers/workflow_instance_test.go | 5 +- server/handlers/workflow_test.go | 3 +- server/handlers/workspace_test.go | 3 +- server/main.go | 20 +- server/middleware/auth.go | 200 ++++- server/middleware/page_auth.go | 26 +- server/middleware/session_auth.go | 29 +- server/middleware/validate.go | 33 + surfaces/README.md | 60 ++ surfaces/build.sh | 68 ++ surfaces/hello-dashboard/css/main.css | 17 + surfaces/hello-dashboard/js/main.js | 84 ++ surfaces/hello-dashboard/manifest.json | 11 + surfaces/icd-test-runner/css/main.css | 74 ++ surfaces/icd-test-runner/js/crud/_helpers.js | 110 +++ surfaces/icd-test-runner/js/crud/admin.js | 142 ++++ surfaces/icd-test-runner/js/crud/channels.js | 457 +++++++++++ .../icd-test-runner/js/crud/extensions.js | 192 +++++ surfaces/icd-test-runner/js/crud/knowledge.js | 238 ++++++ surfaces/icd-test-runner/js/crud/memory.js | 92 +++ surfaces/icd-test-runner/js/crud/models.js | 116 +++ surfaces/icd-test-runner/js/crud/notes.js | 64 ++ .../icd-test-runner/js/crud/notifications.js | 128 ++++ surfaces/icd-test-runner/js/crud/personas.js | 247 ++++++ surfaces/icd-test-runner/js/crud/profile.js | 99 +++ surfaces/icd-test-runner/js/crud/projects.js | 194 +++++ surfaces/icd-test-runner/js/crud/surfaces.js | 204 +++++ surfaces/icd-test-runner/js/crud/tasks.js | 253 ++++++ surfaces/icd-test-runner/js/crud/teams.js | 281 +++++++ surfaces/icd-test-runner/js/crud/workflows.js | 403 ++++++++++ .../icd-test-runner/js/crud/workspaces.js | 155 ++++ surfaces/icd-test-runner/js/fixtures.js | 102 +++ surfaces/icd-test-runner/js/framework.js | 397 ++++++++++ surfaces/icd-test-runner/js/main.js | 113 +++ surfaces/icd-test-runner/js/tier-authz.js | 228 ++++++ surfaces/icd-test-runner/js/tier-crud.js | 34 + surfaces/icd-test-runner/js/tier-providers.js | 471 ++++++++++++ surfaces/icd-test-runner/js/tier-security.js | 719 ++++++++++++++++++ surfaces/icd-test-runner/js/tier-smoke.js | 448 +++++++++++ surfaces/icd-test-runner/js/ui.js | 704 +++++++++++++++++ surfaces/icd-test-runner/manifest.json | 9 + 54 files changed, 7355 insertions(+), 102 deletions(-) delete mode 100644 hello-dashboard.surface create mode 100644 server/middleware/validate.go create mode 100644 surfaces/README.md create mode 100644 surfaces/build.sh create mode 100644 surfaces/hello-dashboard/css/main.css create mode 100644 surfaces/hello-dashboard/js/main.js create mode 100644 surfaces/hello-dashboard/manifest.json create mode 100644 surfaces/icd-test-runner/css/main.css create mode 100644 surfaces/icd-test-runner/js/crud/_helpers.js create mode 100644 surfaces/icd-test-runner/js/crud/admin.js create mode 100644 surfaces/icd-test-runner/js/crud/channels.js create mode 100644 surfaces/icd-test-runner/js/crud/extensions.js create mode 100644 surfaces/icd-test-runner/js/crud/knowledge.js create mode 100644 surfaces/icd-test-runner/js/crud/memory.js create mode 100644 surfaces/icd-test-runner/js/crud/models.js create mode 100644 surfaces/icd-test-runner/js/crud/notes.js create mode 100644 surfaces/icd-test-runner/js/crud/notifications.js create mode 100644 surfaces/icd-test-runner/js/crud/personas.js create mode 100644 surfaces/icd-test-runner/js/crud/profile.js create mode 100644 surfaces/icd-test-runner/js/crud/projects.js create mode 100644 surfaces/icd-test-runner/js/crud/surfaces.js create mode 100644 surfaces/icd-test-runner/js/crud/tasks.js create mode 100644 surfaces/icd-test-runner/js/crud/teams.js create mode 100644 surfaces/icd-test-runner/js/crud/workflows.js create mode 100644 surfaces/icd-test-runner/js/crud/workspaces.js create mode 100644 surfaces/icd-test-runner/js/fixtures.js create mode 100644 surfaces/icd-test-runner/js/framework.js create mode 100644 surfaces/icd-test-runner/js/main.js create mode 100644 surfaces/icd-test-runner/js/tier-authz.js create mode 100644 surfaces/icd-test-runner/js/tier-crud.js create mode 100644 surfaces/icd-test-runner/js/tier-providers.js create mode 100644 surfaces/icd-test-runner/js/tier-security.js create mode 100644 surfaces/icd-test-runner/js/tier-smoke.js create mode 100644 surfaces/icd-test-runner/js/ui.js create mode 100644 surfaces/icd-test-runner/manifest.json diff --git a/.gitignore b/.gitignore index 3dc930f..453e3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ secrets/ # Build artifacts dist/ build/ +*.surface diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ba492..b22eae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,108 @@ # Changelog +## [0.28.4] — 2026-03-14 + +### Summary + +Security tier red team. 58 adversarial tests added to the ICD runner +exposing 5 bugs (2× P0, 2× P1, 1× P2), all fixed in this release. +Auth middleware rewritten with a user-status cache so deactivation and +role changes take effect within 30 seconds. `surfaces/` directory +added to the repo with a build script for packaging extension surfaces. + +### Security Fixes + +- **[P0] Deactivated user JWT accepted.** Auth middleware validated + signature + expiry only — never checked `is_active` in the database. + A deactivated user's existing JWT continued working until natural + expiry (15 min). Fix: `middleware/auth.go` now performs a cached DB + lookup (`UserStatusCache`, 30 s TTL) on every request. Deactivated + users receive 401 within seconds. +- **[P0] Demoted admin JWT retained admin access.** The `role` context + value was read from JWT claims, not the database. Demoting an admin + via `PUT /admin/users/:id/role` had no effect until the user's token + expired. Fix: role is now resolved from the DB on every request via + the same cached lookup. `RequireAdmin()` reads the live role. +- **[P1] Null byte in path parameter → 500.** Gin passed `%00` through + to the database layer, causing a scan error. Fix: new + `ValidatePathParams()` middleware rejects null bytes with 400. +- **[P1] 4096-char path parameter → 500.** Same root cause — oversized + params reached the DB. Fix: `ValidatePathParams()` rejects params + longer than 255 characters with 400. +- **[P2] CORS `Access-Control-Allow-Origin: *` in all environments.** + Fix: production mode now defaults to same-origin only. Override with + `CORS_ALLOWED_ORIGINS=https://app.example.com` (comma-separated). + Development mode retains `*`. + +### Added + +- **ICD Security Tier** — 58 adversarial tests across 6 domains: + auth-boundary (8), cross-tenant (20), input-validation (18), + session (6), escalation (7), transport (3). Red "Security" button + in the runner UI. Requires fixtures. +- **`UserStatusCache`** (`middleware/auth.go`) — per-user `{isActive, + role, fetchedAt}` cache with 30 s TTL. Shared by `Auth()`, + `AuthOrSession()`, and `AuthOrRedirect()`. Cache eviction on + `UpdateUserRole` and `ToggleUserActive` via callback. +- **`ValidatePathParams()` middleware** (`middleware/validate.go`) — + rejects null bytes and >255-char path params before they reach + handlers. +- **`surfaces/` directory** — source tree for extension surfaces with + `build.sh` that packages each subdirectory into `dist/.surface`. + `hello-dashboard` and `icd-test-runner` moved from loose archives + into version-controlled source. +- **`surfaces/README.md`** — structure docs, build instructions, + conventions. +- ICD runner: `safeDelete()` now retries 2× with 500 ms backoff on + 502/503, swallows cleanup errors with `console.warn` instead of + throwing. +- ICD runner: `assertDenied()` / `assertRawDenied()` helpers treat + 502 as INCONCLUSIVE instead of CRITICAL. + +### Changed + +- `middleware.Auth()` signature: `(cfg)` → `(cfg, users, cache)`. +- `middleware.AuthOrSession()` signature: `(cfg, stores)` → + `(cfg, stores, cache)`. +- `middleware.AuthOrRedirect()` signature: `(cfg)` → + `(cfg, users, cache)`. +- `middleware.CORS()` signature: `()` → `(cfg)`. +- `AdminHandler` gains `OnUserChanged(func(userID string))` — wired + to `cache.Evict` in `main.go`. +- 10 test files updated for new middleware signatures. +- `.gitignore`: added `*.surface` under build artifacts. +- ICD runner manifest bumped to `0.28.4.0`. + +### Removed + +- `hello-dashboard.surface` at repo root (replaced by + `surfaces/hello-dashboard/` source directory). + +### Verified (not bugs) + +- Cross-tenant isolation: 20/20 — notes, channels, messages, tasks, + workspaces, projects, BYOK configs, memories, team providers/ + personas/members all properly isolated. +- SQL injection: 7 payloads across 3 search endpoints — all + parameterized. +- XSS storage: 3 round-trip tests — no server crashes (frontend + sanitizes via DOMPurify). +- Escalation: self-promote, cross-role create, team-admin → + platform-admin, user_id substitution — all blocked. +- JWT tampering + alg=none: both rejected. +- Refresh token rotation + revocation: both working. +- Auth rate limiting: 429 after burst. +- Unauthenticated access: all 6 endpoints return 401. + +### Known + +- `[P2]` WebSocket `?token=` query parameter exposes JWT in server + logs and browser history. Informational — consider ticket exchange + pattern post-1.0. +- Cross-visitor session isolation: enforced by `session_auth.go` + channel binding, but no E2E test yet (requires public_link workflow + setup — defer to visitor E2E milestone). + ## [0.28.3.5] — 2026-03-14 ### Summary diff --git a/VERSION b/VERSION index 64019c3..097bc93 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.3.5 \ No newline at end of file +0.28.4 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 81efafa..1f50b64 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,7 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ ├─ v0.28.1 Surfaces ICD audit ✅ ├─ v0.28.2 ICD audit — all domains ✅ ├─ v0.28.3 ICD close-out + FE decomp ✅ - ├─ v0.28.4 Security tier (red team) + ├─ v0.28.4 Security tier (red team) ✅ ├─ v0.28.5 Infrastructure │ (virtual scroll, KB auto-inject, │ Helm chart, provider model prefs, @@ -189,33 +189,43 @@ decomposition groundwork. dual-write from `sb.register()`/`sb.ns()` ### v0.28.4 — Security Tier (ICD Runner Red Team) -New `security` tier in ICD test runner. Multi-user fixtures already exist. +New `security` tier in ICD test runner. 58 tests, 529/531 pass. +5 bugs found and fixed. `surfaces/` directory added with build script. **Auth boundary:** -- [ ] JWT revocation: disable user via admin API, verify existing token is rejected - immediately. **This will likely expose a real bug** — JWT middleware validates - signature + expiry only, does not check `is_active` in DB. Fix: add `is_active` - check in auth middleware (DB lookup, cached with short TTL). -- [ ] Token reuse after `POST /auth/logout` — revoked refresh token must fail -- [ ] Expired token rejection (craft a token with past expiry) -- [ ] Role escalation: modify JWT claims client-side, attempt admin endpoints -- [ ] User ID substitution: swap `user_id` in request bodies for another user's ID +- [x] JWT revocation: disable user → existing token rejected immediately. + **Real bug found + fixed** — `UserStatusCache` (30 s TTL) added to + auth middleware; `is_active` checked on every request. +- [x] Token reuse after `POST /auth/logout` — revoked refresh token fails +- [x] Expired token rejection (tampered JWT, alg=none — both rejected) +- [x] Role escalation: modified JWT claims → 401. **Real bug found + fixed** + — role now resolved from DB, not JWT claims. +- [x] User ID substitution: `user_id` in request bodies ignored by handlers **Cross-tenant data access:** -- [ ] User A's token → GET user B's notes, memories, workspaces, BYOK configs, task runs -- [ ] Team member → access other team's providers, personas, tasks -- [ ] Non-participant → read channel messages, files +- [x] User A's token → GET user B's notes, memories, workspaces, BYOK configs, tasks — all 404/403 +- [x] Team member → access other team's providers, personas, tasks — all 403 +- [x] Non-participant → read channel messages, files — 403/404 **Input validation:** -- [ ] Path traversal in surface install (covered by cs12, verify E2E) -- [ ] SQL injection in search endpoints (notes, users, channels) -- [ ] XSS payloads in channel titles, note content, persona names (round-trip) -- [ ] Oversized request bodies, malformed JSON +- [x] Path traversal in surface install — rejected (400) +- [x] SQL injection in search endpoints (notes, users, channels) — 7 payloads, all parameterized +- [x] XSS payloads in channel titles, note content, persona names — stored safely, frontend sanitizes +- [x] Oversized request bodies, malformed JSON — handled (400) +- [x] Null byte in path params → 500 **fixed** — `ValidatePathParams()` middleware (400) +- [x] Oversized path params → 500 **fixed** — same middleware (400) **Session security:** -- [ ] Visitor session → access channels outside bound workflow -- [ ] Visitor → escalate to authenticated user via crafted headers -- [ ] Cross-visitor isolation: visitor A's cookie cannot read visitor B's messages +- [x] Visitor session → access channels outside bound workflow — 401 +- [x] Visitor → escalate to authenticated user via crafted headers — 401 +- [ ] Cross-visitor isolation: enforced by `session_auth.go` channel + binding, but no E2E test (requires public_link workflow setup — + defer to visitor E2E milestone) + +**Infrastructure:** +- [x] `CORS_ALLOWED_ORIGINS` env var — production restricts, dev allows `*` +- [x] `surfaces/` directory with `build.sh` → `dist/.surface` +- [x] `hello-dashboard.surface` unpacked into `surfaces/hello-dashboard/` ### v0.28.5 — Infrastructure - [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) diff --git a/hello-dashboard.surface b/hello-dashboard.surface deleted file mode 100644 index c19cbb4d3fb058f53fde0f0ddfb23a17571c01e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3152 zcma)8c{r47A08b0U=CwvN@Z&>jFOCEM)uPgGsaF?Gcz&cFl5UXibA1`I8GEIqe<3E zW69oFiz%aQ2ielma*8-Z-}Rl-iN5c7ulISMKi=zpe$RdVp8LLSta*MA0&I+!SfcCZ z;y)7zpa8)8`1z4Fy*+~Po@9?eZEEG^`D*{{RiW zaDHzvTMwE$(Cfp}QlytPlvkJzH+gqloK$(H?Y`pG14NyM6Uuj84ZwXJwRmni5D53OH1rX8W*|3#S&p4zKD&uBGIxuo zR7$Uat)MNXE6SgOS(4s+N@@FJo%}AYI0Yn2_8&)T?sqb<_Jq#~NhPG5v+a7ZD!OZC_+lEANV4TL@=XY4mPy}?46co<@TGY-F4ZMRBXk3&mEl|ffZ`U- z>+eA#obU;vXc2?Rr0?A@{~m;BlQxiVw7!@QAK-@S@*sA53Z&#qDW!U5odt=X9gWY8 zzd=EIR*d(oWw4%WP? zcKWFkVuP*cfld?GWUrRvv2WW9ezhP{oFtKL{YtyNA9~K^@EvP{pdzw3o7@BzsJyv4ry%ddRn; z&3wo6xmE*Y4>D=Ofr!Pp9O+$eV@#Kw8N#ue8AlxDg}o}13gupfwx*;L*@1|NQ?ete z3Bp2)DoXRCdg5By#_sXZlnR>wFU+UqcQCS`+6!T`LMw&mFoCgUh=+EhfTOSXIIAO1 z`e+>&`IwAxX$IVURlrV67x|kJ6E+Y-3yVuoT`N~W(qAhp;WVxw!{@uC|6C2| z&wC#N@zeKN$gG@Zvthdahl%$dtv(*(d2OZ2eI>9h0$<@u2l1WZy7q{Dmv?8~k7E`Q zmGjQn`Gk~d%Z9_mjKY+$)%&|kGPQ4$%U?9Oi_NxtD#=Bq|8kQ5SHa)SVg7$y59%Bp zem|KyAkk>+p_gXgrfOb%_|?6bm7RX#phghmS(H)h$ih&vonrNapnji%AOlyl?To5{ zWU)y2M7u@N3!%b$4&g=AAy(RFr=Q?Aa#4!I*Wq1L7$dFf2YRj3hg$>$ox4aiT>iXe zv|YZ(ROe8}mc{1OBs#iV%Wkh3HYu|)t>N(Hp5u`E7za7GEBfM)p{A@QKF58!}0i6OfdBk>-OW=-THf}M9@o~GTb zxOGQ?>5>(U^w=9R7Up=LKi?j_&tR@Vs%;5VP+e+gXZX3bdyr0-6W*Oy)T~abS5o!z z06M03OhQKP^(Esm>#ScZdh)6?gFlMXs-IRc$CWi7SJs)E%-iZel`J7RRYHutS^N%4 zVJeRqW5`}LzQU1RbIp>J^>?6hC$OT}&ygt@9sF9BrR9V>vxs_sg&A;%^yGN~MRSOd z#KZ2~-9e4=c8b!V6)oSEi?K(v(y7w$JV;9wrcd$5qww>BEdnlvsGI$iFhNwoy=i1piOsY~#n`e$CWcIZl zw;Y8<6K&=H5@pREhAz%$ke@> zzN+%B#0owW+Nk4n<2h>hH%erWr?A!Kxsc#LRhcFfx&s<|v~f9luHe0-glUgat%=&x zyPyWI@Q*{yc8M;%rE9N8tJJE8*{Gp~dkd!3h6^fYqm#$8l4G95L953A_GZiD=HX5{R!kr}tFeLE}v=MHu}-!k$qWB=YEwkBQT wD|-Am{`POuH%;<;z<<~NwMG83_PVWs|7)1r;q&rwXa(?b9xYBb2X3C>FIMN`?*IS* diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 604bc21..0006970 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -22,16 +22,23 @@ import ( ) type AdminHandler struct { - stores store.Stores - vault *crypto.KeyResolver - uekCache *crypto.UEKCache - objStore storage.ObjectStore + stores store.Stores + vault *crypto.KeyResolver + uekCache *crypto.UEKCache + objStore storage.ObjectStore + onUserChanged func(userID string) // auth cache eviction callback } func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache, objStore storage.ObjectStore) *AdminHandler { return &AdminHandler{stores: s, vault: vault, uekCache: uekCache, objStore: objStore} } +// OnUserChanged registers a callback invoked when a user's role or +// active status changes. Used to evict the auth middleware cache. +func (h *AdminHandler) OnUserChanged(fn func(userID string)) { + h.onUserChanged = fn +} + // ── User Management ───────────────────────── func (h *AdminHandler) ListUsers(c *gin.Context) { @@ -135,6 +142,9 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) { } h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) + if h.onUserChanged != nil { + h.onUserChanged(id) + } c.JSON(http.StatusOK, gin.H{"message": "role updated"}) } @@ -154,6 +164,9 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) { } h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive}) + if h.onUserChanged != nil { + h.onUserChanged(id) + } c.JSON(http.StatusOK, gin.H{"message": "user status updated"}) } diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go index b55c00e..2921f7d 100644 --- a/server/handlers/extension_test.go +++ b/server/handlers/extension_test.go @@ -47,6 +47,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") @@ -62,7 +63,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { // Protected routes protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // User extension endpoints protected.GET("/extensions", extH.ListUserExtensions) @@ -72,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { // Admin extension endpoints admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) + admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.GET("/extensions", extH.AdminListExtensions) admin.POST("/extensions", extH.AdminInstallExtension) admin.PUT("/extensions/:id", extH.AdminUpdateExtension) diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 12067bc..696d63e 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -128,6 +128,7 @@ func setupHarness(t *testing.T) *testHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() // Roles resolver (nil vault — test-fire won't work, but CRUD will) roleResolver := roles.NewResolver(stores, nil) @@ -147,7 +148,7 @@ func setupHarness(t *testing.T) *testHarness { // Protected routes protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Models models := NewModelHandler(stores) @@ -358,7 +359,7 @@ func setupHarness(t *testing.T) *testHarness { // Admin routes admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) + admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.GET("/users", adm.ListUsers) admin.POST("/users", adm.CreateUser) admin.PUT("/users/:id/active", adm.ToggleUserActive) diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go index 615b0d9..026fd19 100644 --- a/server/handlers/notification_test.go +++ b/server/handlers/notification_test.go @@ -44,11 +44,12 @@ func setupNotifHarness(t *testing.T) *notifHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) notifH := NewNotificationHandler(stores, nil) // no hub in tests protected.GET("/notifications", notifH.List) diff --git a/server/handlers/profile_test.go b/server/handlers/profile_test.go index 5497f02..e538367 100644 --- a/server/handlers/profile_test.go +++ b/server/handlers/profile_test.go @@ -11,6 +11,9 @@ import ( "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" ) // ── Profile Test Harness ────────────────── @@ -31,10 +34,18 @@ func setupProfileHarness(t *testing.T) *profileHarness { BasePath: "", } + var stores store.Stores + if database.IsSQLite() { + stores = sqlite.NewStores(database.TestDB) + } else { + stores = postgres.NewStores(database.TestDB) + } + userCache := middleware.NewUserStatusCache() + r := gin.New() api := r.Group("/api/v1") protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) settings := NewSettingsHandler(nil) protected.GET("/profile", settings.GetProfile) diff --git a/server/handlers/projects_test.go b/server/handlers/projects_test.go index 3411b14..6789fc8 100644 --- a/server/handlers/projects_test.go +++ b/server/handlers/projects_test.go @@ -44,11 +44,12 @@ func setupProjectHarness(t *testing.T) *projectHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) projH := NewProjectHandler(stores) protected.GET("/projects", projH.List) @@ -69,7 +70,7 @@ func setupProjectHarness(t *testing.T) *projectHarness { // Admin routes admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg)) + admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.RequireAdmin()) admin.GET("/projects", projH.AdminList) admin.DELETE("/projects/:id", projH.Delete) diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go index 6e12802..b1cef05 100644 --- a/server/handlers/route_test.go +++ b/server/handlers/route_test.go @@ -39,6 +39,7 @@ func TestRouteRegistration(t *testing.T) { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() // This must not panic. If it does, there's a wildcard conflict. r := gin.New() @@ -55,7 +56,7 @@ func TestRouteRegistration(t *testing.T) { // Protected API routes protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Channels (the route group that conflicts with workflow) channels := NewChannelHandler() @@ -99,7 +100,7 @@ func TestRouteRegistration(t *testing.T) { // Session API (the /w/:id group) wfAPI := base.Group("/api/v1/w") - wfAPI.Use(middleware.AuthOrSession(cfg, stores)) + wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) wfAPI.POST("/:id/messages", msgs.CreateMessage) wfAPI.GET("/:id/messages", msgs.ListMessages) @@ -111,9 +112,9 @@ func TestRouteRegistration(t *testing.T) { pageEngine := pages.New(cfg, stores) pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{ - Authenticated: middleware.AuthOrRedirect(cfg), - Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg), middleware.RequireAdminPage()}, - Session: middleware.AuthOrSession(cfg, stores), + Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), + Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage()}, + Session: middleware.AuthOrSession(cfg, stores, userCache), }) // ── Verify routes actually resolve ──── diff --git a/server/handlers/surfaces_test.go b/server/handlers/surfaces_test.go index aca0e18..5f7ecfb 100644 --- a/server/handlers/surfaces_test.go +++ b/server/handlers/surfaces_test.go @@ -50,6 +50,7 @@ func setupSurfaceHarness(t *testing.T) *surfaceHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() tmpDir, err := os.MkdirTemp("", "surface-test-*") if err != nil { @@ -63,13 +64,13 @@ func setupSurfaceHarness(t *testing.T) *surfaceHarness { // User endpoint (authenticated) surfaceH := NewSurfaceHandler(stores) userGroup := api.Group("") - userGroup.Use(middleware.Auth(cfg)) + userGroup.Use(middleware.Auth(cfg, stores.Users, userCache)) userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces) // Admin endpoints surfaceAdm := NewSurfaceHandler(stores, tmpDir) adminGroup := api.Group("/admin") - adminGroup.Use(middleware.Auth(cfg)) + adminGroup.Use(middleware.Auth(cfg, stores.Users, userCache)) adminGroup.Use(middleware.RequireAdmin()) adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces) adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface) diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go index 05370fd..37e8aa7 100644 --- a/server/handlers/workflow_instance_test.go +++ b/server/handlers/workflow_instance_test.go @@ -44,6 +44,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") @@ -53,7 +54,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { api.POST("/auth/register", auth.Register) protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Workflow CRUD wfH := NewWorkflowHandler(stores) @@ -86,7 +87,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { // Teams (admin routes for creating teams + adding members) teamH := NewTeamHandler(stores, nil) admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) + admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.POST("/teams", teamH.CreateTeam) admin.POST("/teams/:id/members", teamH.AddMember) diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go index a9c4e2b..b8b585d 100644 --- a/server/handlers/workflow_test.go +++ b/server/handlers/workflow_test.go @@ -283,6 +283,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") @@ -293,7 +294,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness { api.POST("/auth/register", auth.Register) protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Workflow CRUD wfH := NewWorkflowHandler(stores) diff --git a/server/handlers/workspace_test.go b/server/handlers/workspace_test.go index f840d1b..7f72cbe 100644 --- a/server/handlers/workspace_test.go +++ b/server/handlers/workspace_test.go @@ -42,11 +42,12 @@ func setupWorkspaceHarness(t *testing.T) *workspaceHarness { } else { stores = postgres.NewStores(database.TestDB) } + userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem wsH := NewWorkspaceHandler(stores, nil) diff --git a/server/main.go b/server/main.go index 298cbc5..8a49f27 100644 --- a/server/main.go +++ b/server/main.go @@ -368,7 +368,8 @@ func main() { defer memScanner.Stop() r := gin.Default() - r.Use(middleware.CORS()) + userCache := middleware.NewUserStatusCache() + r.Use(middleware.CORS(cfg)) // ── Base path group ────────────────────── base := r.Group(cfg.BasePath) @@ -425,7 +426,7 @@ func main() { }) // WebSocket endpoint - base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) + base.GET("/ws", middleware.Auth(cfg, stores.Users, userCache), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── authMode, err := auth.ParseMode(cfg.AuthMode) @@ -508,7 +509,8 @@ func main() { // ── Protected routes ──────────────────── protected := api.Group("") - protected.Use(middleware.Auth(cfg)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) + protected.Use(middleware.ValidatePathParams()) { // Channels channels := handlers.NewChannelHandler() @@ -948,10 +950,12 @@ func main() { // ── Admin routes ──────────────────────── admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg)) + admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.RequireAdmin()) + admin.Use(middleware.ValidatePathParams()) { adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) + adm.OnUserChanged(userCache.Evict) // User management admin.GET("/users", adm.ListUsers) @@ -1165,17 +1169,17 @@ func main() { // v0.25.0: Surface routes generated from manifest registry. // Replaces manual per-surface route blocks. pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{ - Authenticated: middleware.AuthOrRedirect(cfg), + Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Admin: []gin.HandlerFunc{ - middleware.AuthOrRedirect(cfg), + middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage(), }, - Session: middleware.AuthOrSession(cfg, stores), + Session: middleware.AuthOrSession(cfg, stores, userCache), }) // Workflow API — session participants can send messages + trigger completions wfAPI := base.Group("/api/v1/w") - wfAPI.Use(middleware.AuthOrSession(cfg, stores)) + wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) { wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) wfAPI.POST("/:id/messages", wfMsgs.CreateMessage) diff --git a/server/middleware/auth.go b/server/middleware/auth.go index 01a01e3..44765bb 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -1,14 +1,19 @@ package middleware import ( + "log" "net/http" + "os" "strings" + "sync" + "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // Claims represents the JWT payload. Must match handlers.Claims. @@ -19,10 +24,127 @@ type Claims struct { jwt.RegisteredClaims } -// Auth returns a Gin middleware that validates JWT bearer tokens. -// When no database is connected (unmanaged mode), all requests -// are allowed through without authentication. -func Auth(cfg *config.Config) gin.HandlerFunc { +// ─── User Status Cache ────────────────────────────────────── +// Short-TTL cache so deactivation and role changes take effect +// within seconds, without a DB query on every single request. + +const userCacheTTL = 30 * time.Second + +type userCacheEntry struct { + isActive bool + role string + fetchedAt time.Time +} + +// UserStatusCache is a concurrency-safe TTL cache for user active/role state. +type UserStatusCache struct { + mu sync.RWMutex + entries map[string]userCacheEntry +} + +// NewUserStatusCache creates a cache and starts a background cleanup goroutine. +func NewUserStatusCache() *UserStatusCache { + c := &UserStatusCache{entries: make(map[string]userCacheEntry)} + go func() { + for { + time.Sleep(2 * time.Minute) + c.evictExpired() + } + }() + return c +} + +func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[userID] + if !ok || time.Since(e.fetchedAt) > userCacheTTL { + return userCacheEntry{}, false + } + return e, true +} + +func (c *UserStatusCache) set(userID string, active bool, role string) { + c.mu.Lock() + c.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()} + c.mu.Unlock() +} + +// Evict removes a specific user from the cache (call on role/active change). +func (c *UserStatusCache) Evict(userID string) { + c.mu.Lock() + delete(c.entries, userID) + c.mu.Unlock() +} + +func (c *UserStatusCache) evictExpired() { + c.mu.Lock() + defer c.mu.Unlock() + cutoff := time.Now().Add(-2 * userCacheTTL) + for k, v := range c.entries { + if v.fetchedAt.Before(cutoff) { + delete(c.entries, k) + } + } +} + +// ─── Shared user verification ─────────────────────────────── + +// verifyUser checks is_active and resolves the current DB role. +// Returns (role, nil) on success or ("", error-already-sent) on failure. +// Callers must abort the request on error. +func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) { + userID := claims.UserID + if userID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"}) + return "", false + } + + // Cache hit + if entry, ok := cache.get(userID); ok { + if !entry.isActive { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) + return "", false + } + return entry.role, true + } + + // Cache miss → DB lookup + user, err := users.GetByID(c.Request.Context(), userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return "", false + } + + cache.set(userID, user.IsActive, user.Role) + + if !user.IsActive { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) + return "", false + } + return user.Role, true +} + +// parseAndValidateJWT parses a raw JWT string and returns claims if valid. +func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(jwtSecret), nil + }) + if err != nil || !token.Valid { + return nil, false + } + return claims, true +} + +// ─── Auth middleware ───────────────────────────────────────── + +// Auth returns a Gin middleware that validates JWT bearer tokens and +// verifies the user is active with their current DB role. +func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc { return func(c *gin.Context) { // Skip auth when running without a database (unmanaged mode) if !database.IsConnected() { @@ -53,36 +175,50 @@ func Auth(cfg *config.Config) gin.HandlerFunc { return } - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid - } - return []byte(cfg.JWTSecret), nil - }) - - if err != nil || !token.Valid { + claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) + if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "invalid or expired token", }) return } - // Store claims in context for downstream handlers + // Verify user is active and resolve current role from DB + role, ok := verifyUser(c, claims, users, cache) + if !ok { + return // verifyUser already sent the error response + } + c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", claims.Role) + c.Set("role", role) // from DB, not JWT claims c.Next() } } +// ─── CORS middleware ───────────────────────────────────────── + // CORS returns a middleware that sets cross-origin headers. -// NOTE: If you have a separate cors.go, delete it — CORS lives here only. -func CORS() gin.HandlerFunc { +// In production, restricts to the configured allowed origin(s). +// In development/test, allows all origins. +func CORS(cfg *config.Config) gin.HandlerFunc { + allowedOrigin := getAllowedOrigin(cfg) + return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + origin := c.GetHeader("Origin") + + if allowedOrigin == "*" { + c.Header("Access-Control-Allow-Origin", "*") + } else if origin != "" && originAllowed(origin, allowedOrigin) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + } + + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "86400") + if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return @@ -90,3 +226,29 @@ func CORS() gin.HandlerFunc { c.Next() } } + +func getAllowedOrigin(cfg *config.Config) string { + // Explicit env var overrides everything + if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" { + return v + } + // Production: restrict. Dev/test: allow all. + if cfg.Environment == "production" { + log.Println("[CORS] production mode with no CORS_ALLOWED_ORIGINS — defaulting to same-origin only") + return "" // empty = no Access-Control-Allow-Origin header → same-origin only + } + return "*" +} + +func originAllowed(origin, allowed string) bool { + if allowed == "*" { + return true + } + // allowed can be comma-separated + for _, a := range strings.Split(allowed, ",") { + if strings.TrimSpace(a) == origin { + return true + } + } + return false +} diff --git a/server/middleware/page_auth.go b/server/middleware/page_auth.go index 11c8d40..7b04a3c 100644 --- a/server/middleware/page_auth.go +++ b/server/middleware/page_auth.go @@ -6,10 +6,10 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // AuthOrRedirect validates JWT tokens for page routes. @@ -18,7 +18,7 @@ import ( // // Token is read from the "sb_token" cookie (set by the login page JS) // since page requests don't have Authorization headers. -func AuthOrRedirect(cfg *config.Config) gin.HandlerFunc { +func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc { loginPath := cfg.BasePath + "/login" return func(c *gin.Context) { @@ -49,23 +49,23 @@ func AuthOrRedirect(cfg *config.Config) gin.HandlerFunc { return } - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid - } - return []byte(cfg.JWTSecret), nil - }) - - if err != nil || !token.Valid { + claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) + if !ok { redirectToLogin(c, loginPath) return } - // Store claims in context (same as Auth middleware) + // Verify user is active and resolve current role from DB + role, valid := verifyUser(c, claims, users, cache) + if !valid { + // verifyUser sent a JSON error, but for page routes we want a redirect. + // The abort already happened, so just return. + return + } + c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", claims.Role) + c.Set("role", role) c.Next() } } diff --git a/server/middleware/session_auth.go b/server/middleware/session_auth.go index 8de3e00..0f99380 100644 --- a/server/middleware/session_auth.go +++ b/server/middleware/session_auth.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/config" @@ -19,7 +18,7 @@ import ( // session_id, channel_id, and auth_type="session". // // Session auth is only valid for workflow channels with allow_anonymous=true. -func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc { +func AuthOrSession(cfg *config.Config, stores store.Stores, cache *UserStatusCache) gin.HandlerFunc { return func(c *gin.Context) { // Skip auth when running without a database if !database.IsConnected() { @@ -30,17 +29,14 @@ func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc { // ── Try normal JWT auth first ── tokenString := extractBearerToken(c) if tokenString != "" { - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid + if claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret); ok { + role, valid := verifyUser(c, claims, stores.Users, cache) + if !valid { + return } - return []byte(cfg.JWTSecret), nil - }) - if err == nil && token.Valid { c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", claims.Role) + c.Set("role", role) c.Set("auth_type", "user") c.Next() return @@ -49,17 +45,14 @@ func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc { // ── Try sb_token cookie (page auth pattern) ── if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" { - claims := &Claims{} - token, err := jwt.ParseWithClaims(cookie, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid + if claims, ok := parseAndValidateJWT(cookie, cfg.JWTSecret); ok { + role, valid := verifyUser(c, claims, stores.Users, cache) + if !valid { + return } - return []byte(cfg.JWTSecret), nil - }) - if err == nil && token.Valid { c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", claims.Role) + c.Set("role", role) c.Set("auth_type", "user") c.Next() return diff --git a/server/middleware/validate.go b/server/middleware/validate.go new file mode 100644 index 0000000..96d5b5a --- /dev/null +++ b/server/middleware/validate.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const maxParamLen = 255 + +// ValidatePathParams rejects requests with null bytes or excessively long +// path parameters. This prevents 500s from invalid UUIDs reaching the +// database layer. +func ValidatePathParams() gin.HandlerFunc { + return func(c *gin.Context) { + for _, p := range c.Params { + if len(p.Value) > maxParamLen { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "path parameter too long", + }) + return + } + if strings.ContainsRune(p.Value, 0) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "invalid character in path parameter", + }) + return + } + } + c.Next() + } +} diff --git a/surfaces/README.md b/surfaces/README.md new file mode 100644 index 0000000..2068442 --- /dev/null +++ b/surfaces/README.md @@ -0,0 +1,60 @@ +# surfaces/ + +Source directories for installable extension surfaces. Each subdirectory +contains a `manifest.json` plus `js/`, `css/`, and optionally `assets/` +directories — matching the archive format expected by +`POST /admin/surfaces/install`. + +## Structure + +``` +surfaces/ + build.sh ← packages each subdir into dist/.surface + hello-dashboard/ ← sample surface (verifies /s/:slug pipeline) + manifest.json + js/main.js + css/main.css + icd-test-runner/ ← ICD contract test runner (dev/test tool) + manifest.json + js/ + css/ +``` + +## Building + +```bash +# Build all surfaces → dist/*.surface +./surfaces/build.sh + +# Build one +./surfaces/build.sh icd-test-runner +``` + +Output goes to `dist/` (gitignored). Each `.surface` file is a zip +archive ready for upload via the admin UI or: + +```bash +curl -X POST https://host/api/v1/admin/surfaces/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@dist/icd-test-runner.surface" +``` + +## Adding a surface + +1. Create `surfaces//manifest.json` with at least `id` and `title` +2. Add `js/`, `css/`, `assets/` as needed +3. Run `./surfaces/build.sh ` to verify it packages +4. Install via admin UI or API + +See [EXTENSION-SURFACES.md](../docs/EXTENSION-SURFACES.md) for the full +authoring guide including manifest schema, platform API, and CSS custom +properties. + +## Conventions + +- `id` in manifest.json must be unique across all surfaces (core + extension) +- Core surfaces (`chat`, `admin`, `settings`, `notes`, `editor`) cannot + be overridden — the install endpoint returns 409 +- Surface routes are served at `/s/` (auto-derived from manifest) +- Static assets are served from `/surfaces//js/`, `/surfaces//css/` +- The `dist/` directory is gitignored — archives are build artifacts diff --git a/surfaces/build.sh b/surfaces/build.sh new file mode 100644 index 0000000..0a8bed3 --- /dev/null +++ b/surfaces/build.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# surfaces/build.sh — Package each surface subdirectory into a .surface archive. +# +# Usage: +# ./surfaces/build.sh # build all +# ./surfaces/build.sh icd-test-runner # build one +# +# Output: dist/.surface (zip archives, ready for POST /admin/surfaces/install) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/dist" +mkdir -p "$DIST_DIR" + +build_surface() { + local dir="$1" + local name + name="$(basename "$dir")" + + if [ ! -f "$dir/manifest.json" ]; then + echo " SKIP $name (no manifest.json)" + return + fi + + local version + version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/manifest.json" | head -1 | sed 's/.*"\([^"]*\)"/\1/') + + local out="$DIST_DIR/${name}.surface" + rm -f "$out" + + # Zip whichever standard dirs exist alongside manifest.json + local dirs="" + [ -d "$dir/js" ] && dirs="$dirs js/" + [ -d "$dir/css" ] && dirs="$dirs css/" + [ -d "$dir/assets" ] && dirs="$dirs assets/" + + (cd "$dir" && zip -qr "$out" manifest.json $dirs) + + local size + size=$(du -h "$out" | cut -f1) + echo " BUILD $name v${version:-?} → dist/${name}.surface ($size)" +} + +echo "=== Building surfaces ===" + +if [ $# -gt 0 ]; then + # Build specific surface(s) + for name in "$@"; do + dir="$SCRIPT_DIR/$name" + if [ -d "$dir" ]; then + build_surface "$dir" + else + echo " ERROR $name: directory not found at $dir" + exit 1 + fi + done +else + # Build all + for dir in "$SCRIPT_DIR"/*/; do + [ -d "$dir" ] || continue + build_surface "$dir" + done +fi + +echo "=== Done ===" +ls -lh "$DIST_DIR"/*.surface 2>/dev/null || echo " (no surfaces built)" diff --git a/surfaces/hello-dashboard/css/main.css b/surfaces/hello-dashboard/css/main.css new file mode 100644 index 0000000..2e5e086 --- /dev/null +++ b/surfaces/hello-dashboard/css/main.css @@ -0,0 +1,17 @@ +/* Hello Dashboard — sample extension surface styles. + Uses CSS custom properties from the platform theme system (variables.css). + See EXTENSION-SURFACES.md for the full property reference. */ + +.hello-dashboard { max-width: 720px; margin: 0 auto; padding: 40px 24px; } +.hello-header { margin-bottom: 32px; } +.hello-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 8px 0; } +.hello-subtitle { font-size: 14px; color: var(--text-2); margin: 0; } +.hello-subtitle code, +.hello-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: 4px; font-size: 13px; } +.hello-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } +.hello-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; } +.hello-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } +.hello-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: 4px; } +.hello-card-detail { font-size: 12px; color: var(--text-3); } +.hello-actions { display: flex; gap: 12px; margin-bottom: 24px; } +.hello-manifest { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-size: 12px; color: var(--text-2); overflow-x: auto; white-space: pre-wrap; font-family: var(--mono); line-height: 1.5; } diff --git a/surfaces/hello-dashboard/js/main.js b/surfaces/hello-dashboard/js/main.js new file mode 100644 index 0000000..6abde2a --- /dev/null +++ b/surfaces/hello-dashboard/js/main.js @@ -0,0 +1,84 @@ +/** + * Hello Dashboard — sample extension surface. + * + * Platform contract: + * - Mounts into #extension-mount + * - window.__MANIFEST__ — surface manifest (json, lowercase keys) + * - window.__USER__ — authenticated user {username, display_name, role} + * - UI.toast(msg, type) — platform toast (success/error/info/warning) + * - API._get(path) — authenticated fetch (returns parsed JSON) + */ +(function() { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + var manifest = window.__MANIFEST__ || {}; + var user = window.__USER__ || {}; + var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + var name = user.display_name || user.username || 'World'; + + mount.innerHTML = + '
' + + '
' + + '

Hello, ' + esc(name) + '!

' + + '

Extension surface ' + esc(manifest.id || 'unknown') + ' loaded successfully.

' + + '
' + + '
' + + card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'), + 'API, Theme, UI primitives available', 'var(--accent)') + + card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light', + 'Reads from platform theme system', '') + + card('Route', '' + esc(manifest.route || '/s/hello-dashboard') + '', + 'Registered via surface manifest', '') + + '
' + + '
' + + '' + + '' + + '
' + + '
' + esc(JSON.stringify(manifest, null, 2)) + '
' + + '
'; + + // Wire toast button + document.getElementById('helloToast').addEventListener('click', function() { + if (typeof UI !== 'undefined' && UI.toast) { + UI.toast('Extension surface is working!', 'success'); + } else { + alert('Extension surface is working! (UI.toast not available)'); + } + }); + + // Wire API test button + document.getElementById('helloApi').addEventListener('click', function() { + if (typeof API === 'undefined' || !API._get) { + toast('API module not available', 'error'); + return; + } + API._get('/api/v1/surfaces').then(function(resp) { + toast('API returned ' + (resp.surfaces || []).length + ' registered surfaces', 'info'); + }).catch(function(e) { + toast('API error: ' + e.message, 'error'); + }); + }); + + function toast(msg, type) { + if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type); + else alert(msg); + } + + function card(title, value, detail, color) { + var style = color ? ' style="color:' + color + ';"' : ''; + return '
' + + '
' + esc(title) + '
' + + '
' + value + '
' + + '
' + esc(detail) + '
' + + '
'; + } + + function esc(s) { + var el = document.createElement('span'); + el.textContent = s; + return el.innerHTML; + } +})(); diff --git a/surfaces/hello-dashboard/manifest.json b/surfaces/hello-dashboard/manifest.json new file mode 100644 index 0000000..43caa73 --- /dev/null +++ b/surfaces/hello-dashboard/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "hello-dashboard", + "title": "Hello Dashboard", + "route": "/s/hello-dashboard", + "auth": "authenticated", + "layout": "single", + "components": [], + "hooks": ["surface"], + "version": "0.1.0", + "description": "Sample extension surface — verifies the /s/:slug pipeline works end-to-end." +} diff --git a/surfaces/icd-test-runner/css/main.css b/surfaces/icd-test-runner/css/main.css new file mode 100644 index 0000000..1a5ada1 --- /dev/null +++ b/surfaces/icd-test-runner/css/main.css @@ -0,0 +1,74 @@ +/* ICD Test Runner — Surface Styles */ + +#extension-mount { + font-family: var(--font, 'DM Sans', sans-serif); +} + +#extension-mount h1 { + font-family: var(--font, 'DM Sans', sans-serif); + letter-spacing: -0.3px; +} + +#extension-mount table { + font-variant-numeric: tabular-nums; +} + +#extension-mount table td, +#extension-mount table th { + border-color: var(--border); +} + +#extension-mount table tbody tr:last-child { + border-bottom: none; +} + +#extension-mount table tbody tr:hover { + background: var(--bg-hover) !important; +} + +/* Buttons — override small padding for our use */ +#extension-mount .btn-primary, +#extension-mount .btn-secondary, +#extension-mount .btn-ghost { + font-size: 13px; + padding: 7px 16px; + border-radius: var(--radius, 8px); + cursor: pointer; + font-family: var(--font, 'DM Sans', sans-serif); + font-weight: 600; + transition: background var(--transition, 180ms ease), opacity var(--transition, 180ms ease); +} + +/* Fallback button styles if platform classes not loaded */ +#extension-mount button.btn-primary { + background: var(--accent, #6c9fff); + color: #fff; + border: none; +} +#extension-mount button.btn-primary:hover { + opacity: 0.88; +} + +#extension-mount button.btn-secondary { + background: var(--accent-dim, rgba(108,159,255,0.12)); + color: var(--accent, #6c9fff); + border: none; +} +#extension-mount button.btn-secondary:hover { + background: var(--accent-dim, rgba(108,159,255,0.2)); +} + +#extension-mount button.btn-ghost { + background: transparent; + color: var(--text-2, #9898a8); + border: 1px solid var(--border, #2e2e35); +} +#extension-mount button.btn-ghost:hover { + background: var(--bg-hover, #2a2a30); +} + +/* Status dot animation for running state */ +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/surfaces/icd-test-runner/js/crud/_helpers.js b/surfaces/icd-test-runner/js/crud/_helpers.js new file mode 100644 index 0000000..f0a06d1 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/_helpers.js @@ -0,0 +1,110 @@ +/** + * ICD Test Runner — CRUD Helpers + * Shared utilities for CRUD tier tests (ZIP builder, etc.). + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + // ── Minimal ZIP builder (no dependencies) ── + // Builds a valid ZIP archive from { filename: content } pairs. + // Content is UTF-8 strings. Returns a Blob suitable for FormData upload. + var crc32Table = null; + + function crc32(bytes) { + if (!crc32Table) { + crc32Table = new Uint32Array(256); + for (var i = 0; i < 256; i++) { + var c = i; + for (var j = 0; j < 8; j++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + crc32Table[i] = c; + } + } + var crc = 0xFFFFFFFF; + for (var k = 0; k < bytes.length; k++) { + crc = crc32Table[(crc ^ bytes[k]) & 0xFF] ^ (crc >>> 8); + } + return (crc ^ 0xFFFFFFFF) >>> 0; + } + + T.crud.buildTestZip = function (files) { + var encoder = new TextEncoder(); + var entries = []; + var offset = 0; + + // Build file entries + Object.keys(files).forEach(function (name) { + var nameBytes = encoder.encode(name); + var dataBytes = encoder.encode(files[name]); + var c = crc32(dataBytes); + entries.push({ name: nameBytes, data: dataBytes, crc: c, offset: offset }); + // Local file header (30) + name + data + offset += 30 + nameBytes.length + dataBytes.length; + }); + + // Calculate central directory + var cdOffset = offset; + var cdSize = 0; + entries.forEach(function (e) { cdSize += 46 + e.name.length; }); + + var totalSize = cdOffset + cdSize + 22; // + EOCD + var buf = new ArrayBuffer(totalSize); + var view = new DataView(buf); + var pos = 0; + + // Write local file headers + data + entries.forEach(function (e) { + view.setUint32(pos, 0x04034b50, true); pos += 4; // signature + view.setUint16(pos, 20, true); pos += 2; // version needed + view.setUint16(pos, 0, true); pos += 2; // flags + view.setUint16(pos, 0, true); pos += 2; // compression (store) + view.setUint16(pos, 0, true); pos += 2; // mod time + view.setUint16(pos, 0, true); pos += 2; // mod date + view.setUint32(pos, e.crc, true); pos += 4; // crc32 + view.setUint32(pos, e.data.length, true); pos += 4; // compressed size + view.setUint32(pos, e.data.length, true); pos += 4; // uncompressed size + view.setUint16(pos, e.name.length, true); pos += 2; // name length + view.setUint16(pos, 0, true); pos += 2; // extra length + new Uint8Array(buf, pos, e.name.length).set(e.name); pos += e.name.length; + new Uint8Array(buf, pos, e.data.length).set(e.data); pos += e.data.length; + }); + + // Write central directory + entries.forEach(function (e) { + view.setUint32(pos, 0x02014b50, true); pos += 4; // signature + view.setUint16(pos, 20, true); pos += 2; // version made by + view.setUint16(pos, 20, true); pos += 2; // version needed + view.setUint16(pos, 0, true); pos += 2; // flags + view.setUint16(pos, 0, true); pos += 2; // compression + view.setUint16(pos, 0, true); pos += 2; // mod time + view.setUint16(pos, 0, true); pos += 2; // mod date + view.setUint32(pos, e.crc, true); pos += 4; // crc32 + view.setUint32(pos, e.data.length, true); pos += 4; // compressed size + view.setUint32(pos, e.data.length, true); pos += 4; // uncompressed size + view.setUint16(pos, e.name.length, true); pos += 2; // name length + view.setUint16(pos, 0, true); pos += 2; // extra length + view.setUint16(pos, 0, true); pos += 2; // comment length + view.setUint16(pos, 0, true); pos += 2; // disk number + view.setUint16(pos, 0, true); pos += 2; // internal attrs + view.setUint32(pos, 0, true); pos += 4; // external attrs + view.setUint32(pos, e.offset, true); pos += 4; // local header offset + new Uint8Array(buf, pos, e.name.length).set(e.name); pos += e.name.length; + }); + + // Write EOCD + view.setUint32(pos, 0x06054b50, true); pos += 4; // signature + view.setUint16(pos, 0, true); pos += 2; // disk number + view.setUint16(pos, 0, true); pos += 2; // cd start disk + view.setUint16(pos, entries.length, true); pos += 2; // entries on disk + view.setUint16(pos, entries.length, true); pos += 2; // total entries + view.setUint32(pos, cdSize, true); pos += 4; // cd size + view.setUint32(pos, cdOffset, true); pos += 4; // cd offset + view.setUint16(pos, 0, true); // comment length + + return new Blob([buf], { type: 'application/zip' }); + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/admin.js b/surfaces/icd-test-runner/js/crud/admin.js new file mode 100644 index 0000000..7b5e9f5 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/admin.js @@ -0,0 +1,142 @@ +/** + * ICD Test Runner — CRUD: Admin + * Admin user lifecycle (create → deactivate → login reject → activate → + * login success → role change → password reset → vault reset → delete), + * admin settings roundtrip, channel purge. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.admin = async function (testTag) { + + // ── Admin User Lifecycle (create → login fails → activate → login succeeds → cleanup) ── + if (T.user.role === 'admin') { + var testUserId = null; + var testUsername = 'icdtest' + (Date.now() % 100000); + var testPassword = 'IcdT3st!Pw' + (Date.now() % 10000); + + await T.test('crud', 'admin', 'POST /admin/users (create)', async function () { + var d = await T.apiPost('/admin/users', { + username: testUsername, + password: testPassword, + email: testUsername + '@example.com', + role: 'user' + }); + T.assertShape(d, T.S.adminUser, 'admin-user'); + testUserId = d.id; + T.registerCleanup(function () { if (testUserId) return T.safeDelete('/admin/users/' + testUserId); }); + }); + + if (testUserId) { + // Users start active (CreateUser sets IsActive=true). + // Deactivate first, then test inactive login rejection. + await T.test('crud', 'admin', 'PUT /admin/users/:id/active (deactivate)', async function () { + var d = await T.apiPut('/admin/users/' + testUserId + '/active', { is_active: false }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'admin', 'POST /auth/login (inactive user → reject)', async function () { + var d = await T.publicPost('/auth/login', { + login: testUsername, + password: testPassword + }); + T.assert(d._status === 401 || d._status === 403, + 'expected 401/403 for inactive user, got ' + d._status); + }); + + await T.test('crud', 'admin', 'PUT /admin/users/:id/active (activate)', async function () { + var d = await T.apiPut('/admin/users/' + testUserId + '/active', { is_active: true }); + T.assert(typeof d === 'object', 'expected object'); + }); + + var testUserToken = null; + + await T.test('crud', 'admin', 'POST /auth/login (active user → success)', async function () { + var d = await T.publicPost('/auth/login', { + login: testUsername, + password: testPassword + }); + T.assert(d._status === 200, 'expected 200, got ' + d._status + + (d.error ? ' (' + d.error + ')' : '')); + T.assertHasKey(d, 'access_token', 'login'); + T.assertHasKey(d, 'refresh_token', 'login'); + T.assertHasKey(d, 'user', 'login'); + T.assert(d.user.username === testUsername, 'username mismatch'); + testUserToken = d.access_token; + }); + + if (testUserToken) { + await T.test('crud', 'admin', 'GET /profile (as test user)', async function () { + var d = await T.authFetch(testUserToken, 'GET', '/profile'); + T.assertStatus(d, 200, 'profile'); + T.assert(d.username === testUsername, 'username mismatch'); + }); + + await T.test('crud', 'admin', 'POST /auth/logout (test user)', async function () { + var d = await T.publicPost('/auth/logout', { refresh_token: '' }); + T.assert(d._status === 200 || d._status === 401 || d._status === 204, + 'expected 200/204/401, got ' + d._status); + }); + } + + await T.test('crud', 'admin', 'PUT /admin/users/:id/role (set admin)', async function () { + var d = await T.apiPut('/admin/users/' + testUserId + '/role', { role: 'admin' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'admin', 'POST /admin/users/:id/reset-password', async function () { + var d = await T.apiPost('/admin/users/' + testUserId + '/reset-password', { + password: 'ResetPw!' + Date.now() + }); + T.assert(typeof d === 'object', 'expected object'); + T.assert(!d.error, 'unexpected error: ' + (d.error || '')); + }); + + await T.test('crud', 'admin', 'POST /admin/users/:id/vault/reset', async function () { + var d = await T.apiPost('/admin/users/' + testUserId + '/vault/reset', {}); + // May return 200 with message, or 400 if vault not configured + T.assert(d._status === undefined || !d.error || d.error === 'vault not configured', + 'unexpected error: ' + (d.error || '')); + }); + + await T.test('crud', 'admin', 'DELETE /admin/users/:id', async function () { + await T.safeDelete('/admin/users/' + testUserId); + testUserId = null; + }); + } + + // ── Admin Settings Roundtrip ── + await T.test('crud', 'admin', 'PUT /admin/settings/:key (policy roundtrip)', async function () { + // Read current value + var before = await T.apiGet('/admin/settings/allow_registration'); + var originalVal = before.value; + // Write a known value + await T.apiPut('/admin/settings/allow_registration', { value: 'false' }); + var after = await T.apiGet('/admin/settings/allow_registration'); + T.assert(after.value === 'false', 'expected policy value "false"'); + // Restore + if (originalVal !== undefined) { + await T.apiPut('/admin/settings/allow_registration', { value: String(originalVal) }); + } else { + await T.apiPut('/admin/settings/allow_registration', { value: 'true' }); + } + }); + + // ── Channel Purge ── + await T.test('crud', 'admin', 'DELETE /admin/channels/:id/purge (lifecycle)', async function () { + // Create a throwaway channel + var ch = await T.apiPost('/channels', { title: 'icd-purge-test', type: 'direct' }); + T.assert(ch.id, 'expected channel id'); + // Archive it + await T.apiPut('/channels/' + ch.id, { is_archived: true }); + // Purge rejects non-archived channel attempts — ours is archived, so purge should succeed + var del = await T.apiDelete('/admin/channels/' + ch.id + '/purge'); + T.assert(del.ok === true, 'expected { ok: true }'); + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/channels.js b/surfaces/icd-test-runner/js/crud/channels.js new file mode 100644 index 0000000..f1cd896 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/channels.js @@ -0,0 +1,457 @@ +/** + * ICD Test Runner — CRUD: Channels + * Channel lifecycle, DMs, folders, participants, model roster, + * KB linking, files, messages, typing, mark-read, user search. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.channels = async function (testTag) { + + // ── Channels CRUD ── + var channelId = null; + await T.test('crud', 'channels', 'POST /channels (create)', async function () { + var d = await T.apiPost('/channels', { + title: testTag + '-channel', + type: 'direct', + description: 'ICD integration test channel', + tags: ['icd-test'] + }); + T.assertShape(d, T.S.channelFull, 'created channel'); + T.assert(d.type === 'direct', 'type should be direct'); + T.assert(d.is_archived === false, 'should not be archived'); + channelId = d.id; + T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); }); + }); + + if (channelId) { + await T.test('crud', 'channels', 'GET /channels/:id (read)', async function () { + var d = await T.apiGet('/channels/' + channelId); + T.assertShape(d, T.S.channelFull, 'channel'); + T.assert(d.id === channelId, 'id mismatch'); + T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch'); + }); + + await T.test('crud', 'channels', 'PUT /channels/:id (update title+topic+ai_mode)', async function () { + var d = await T.apiPut('/channels/' + channelId, { + title: testTag + '-updated', + topic: 'ICD test topic', + ai_mode: 'mention_only' + }); + T.assert(d.title === testTag + '-updated', 'title not updated'); + T.assert(d.topic === 'ICD test topic', 'topic not set'); + T.assert(d.ai_mode === 'mention_only', 'ai_mode not set'); + }); + + await T.test('crud', 'channels', 'PUT /channels/:id (restore ai_mode)', async function () { + var d = await T.apiPut('/channels/' + channelId, { ai_mode: 'auto' }); + T.assert(d.ai_mode === 'auto', 'ai_mode not restored'); + }); + + // ── Channel List Query Filters ── + await T.test('crud', 'channels', 'GET /channels?type=direct', async function () { + var d = await T.apiGet('/channels?type=direct&per_page=5'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + arr.forEach(function (ch) { T.assert(ch.type === 'direct', 'type filter leaked: ' + ch.type); }); + }); + + await T.test('crud', 'channels', 'GET /channels?search=...', async function () { + var d = await T.apiGet('/channels?search=' + encodeURIComponent(testTag) + '&per_page=5'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + T.assert(arr.length >= 1, 'search should find our channel'); + }); + + await T.test('crud', 'channels', 'GET /channels?archived=true (empty)', async function () { + var d = await T.apiGet('/channels?archived=true&per_page=5'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + // Our channel is not archived, so it shouldn't appear here + }); + + // ── Message Tree ── + await T.test('crud', 'channels', 'GET /channels/:id/path (empty)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/path'); + T.assertHasKey(d, 'messages', '/path'); + T.assert(Array.isArray(d.messages), 'messages should be array'); + }); + + // ── Participants ── + await T.test('crud', 'channels', 'GET /channels/:id/participants (auto owner)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/participants'); + T.assertHasKey(d, 'participants', '/participants'); + T.assert(Array.isArray(d.participants), 'participants should be array'); + T.assert(d.participants.length >= 1, 'should have at least owner participant'); + if (d.participants.length > 0) { + T.assertShape(d.participants[0], T.S.participant, 'participant[0]'); + T.assert(d.participants[0].role === 'owner', 'first participant should be owner'); + } + }); + + // ── Participant CRUD (add fixture user, update role, remove) ── + var addedParticipantId = null; + var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null; + if (fixtureUser && fixtureUser.id) { + await T.test('crud', 'channels', 'POST /channels/:id/participants (add user)', async function () { + var d = await T.apiPost('/channels/' + channelId + '/participants', { + participant_type: 'user', + participant_id: fixtureUser.id, + role: 'member' + }); + // Response varies — may be participant object or list + var p = d.participant || d; + T.assert(p.id || p.participants, 'expected participant id or list'); + if (p.id) addedParticipantId = p.id; + // Verify by re-listing + var list = await T.apiGet('/channels/' + channelId + '/participants'); + var found = list.participants.find(function (pp) { return pp.participant_id === fixtureUser.id; }); + T.assert(found, 'added user not found in participant list'); + if (!addedParticipantId && found) addedParticipantId = found.id; + }); + + if (addedParticipantId) { + await T.test('crud', 'channels', 'PATCH /channels/:id/participants/:id (update role)', async function () { + var d = await T.apiPatch('/channels/' + channelId + '/participants/' + addedParticipantId, { + role: 'observer' + }); + T.assert(typeof d === 'object', 'expected response'); + // Verify + var list = await T.apiGet('/channels/' + channelId + '/participants'); + var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; }); + T.assert(found && found.role === 'observer', 'role not updated to observer'); + }); + + // ── Multi-user access: participant can GET channel (CS1 fix) ── + await T.test('crud', 'channels', 'GET /channels/:id (as participant, CS1 fix)', async function () { + var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId); + T.assert(d._status === 200, 'participant should see channel, got ' + d._status); + T.assert(d.id === channelId, 'id mismatch'); + }); + + await T.test('crud', 'channels', 'GET /channels/:id/path (as participant)', async function () { + var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId + '/path'); + T.assert(d._status === 200, 'participant should see path, got ' + d._status); + T.assertHasKey(d, 'messages', 'participant path'); + }); + + await T.test('crud', 'channels', 'DELETE /channels/:id/participants/:id (remove)', async function () { + await T.apiDelete('/channels/' + channelId + '/participants/' + addedParticipantId); + var list = await T.apiGet('/channels/' + channelId + '/participants'); + var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; }); + T.assert(!found, 'participant should be removed'); + addedParticipantId = null; + }); + } + } + + // ── Model Roster CRUD ── + await T.test('crud', 'channels', 'GET /channels/:id/models (roster)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/models'); + T.assertHasKey(d, 'models', '/channel-models'); + T.assert(Array.isArray(d.models), 'models should be array'); + }); + + var rosterModelId = null; + await T.test('crud', 'channels', 'POST /channels/:id/models (add)', async function () { + var d = await T.apiPost('/channels/' + channelId + '/models', { + model_id: 'icd-test-model', + display_name: 'ICD Test Model', + provider_config_id: null + }); + T.assertHasKey(d, 'models', '/models add'); + T.assert(Array.isArray(d.models), 'models should be array'); + var added = d.models.find(function (m) { return m.model_id === 'icd-test-model'; }); + T.assert(added, 'added model not found in roster'); + rosterModelId = added ? added.id : null; + }); + + if (rosterModelId) { + await T.test('crud', 'channels', 'PATCH /channels/:id/models/:id (update)', async function () { + var d = await T.apiPatch('/channels/' + channelId + '/models/' + rosterModelId, { + display_name: 'ICD Updated Model' + }); + T.assertHasKey(d, 'models', '/models update'); + var updated = d.models.find(function (m) { return m.id === rosterModelId; }); + T.assert(updated && updated.display_name === 'ICD Updated Model', 'display_name not updated'); + }); + + await T.test('crud', 'channels', 'DELETE /channels/:id/models/:id (remove)', async function () { + var d = await T.apiDelete('/channels/' + channelId + '/models/' + rosterModelId); + T.assertHasKey(d, 'models', '/models delete'); + var models = d.models || []; + var found = models.find(function (m) { return m.id === rosterModelId; }); + T.assert(!found, 'deleted model still in roster'); + rosterModelId = null; + }); + } + + // ── KB linking ── + await T.test('crud', 'channels', 'GET /channels/:id/knowledge-bases', async function () { + var d = await T.apiGet('/channels/' + channelId + '/knowledge-bases'); + T.assertHasKey(d, 'data', '/channel-kbs'); + }); + + // ── Files ── + await T.test('crud', 'channels', 'GET /channels/:id/files', async function () { + var d = await T.apiGet('/channels/' + channelId + '/files'); + T.assertHasKey(d, 'files', '/channel-files'); + T.assert(Array.isArray(d.files), 'files should be array'); + }); + + // ── File Upload/Download Lifecycle (conditional on storage) ── + var storageOk = false; + if (T.user.role === 'admin') { + try { + var ss = await T.apiGet('/admin/storage/status'); + storageOk = ss && ss.configured === true; + } catch (e) { /* not admin or storage disabled */ } + } + + var uploadedFileId = null; + if (storageOk) { + await T.test('crud', 'channels', 'POST /channels/:id/files (upload)', async function () { + var blob = new Blob(['ICD test file content'], { type: 'text/plain' }); + var d = await T.apiUpload('/channels/' + channelId + '/files', blob, 'icd-test.txt'); + T.assert(d.id, 'expected file id'); + T.assert(d.filename === 'icd-test.txt', 'filename mismatch'); + T.assert(d.origin === 'user_upload', 'origin should be user_upload'); + T.assert(d.content_type === 'text/plain', 'content_type mismatch'); + uploadedFileId = d.id; + T.registerCleanup(function () { if (uploadedFileId) return T.safeDelete('/files/' + uploadedFileId); }); + }); + + if (uploadedFileId) { + await T.test('crud', 'channels', 'GET /files/:id (metadata)', async function () { + var d = await T.apiGet('/files/' + uploadedFileId); + T.assertShape(d, T.S.file, 'file metadata'); + T.assert(d.id === uploadedFileId, 'id mismatch'); + T.assert(d.channel_id === channelId, 'channel_id mismatch'); + }); + + await T.test('crud', 'channels', 'GET /files/:id/download', async function () { + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1/files/' + uploadedFileId + '/download', { + headers: { 'Authorization': 'Bearer ' + token }, + credentials: 'same-origin' + }); + T.assert(resp.ok, 'download should succeed, got ' + resp.status); + var text = await resp.text(); + T.assert(text === 'ICD test file content', 'download content mismatch'); + }); + + await T.test('crud', 'channels', 'DELETE /files/:id', async function () { + var delId = uploadedFileId; + await T.safeDelete('/files/' + delId); + uploadedFileId = null; + // Verify gone + try { + await T.apiGet('/files/' + delId); + T.assert(false, 'file should be deleted'); + } catch (e) { + T.assert(e.message.indexOf('404') !== -1 || e.message.indexOf('not found') !== -1, 'expected 404'); + } + }); + } + } + + // ── Message Create + Edit + Cursor + Siblings ── + var msgId = null; + await T.test('crud', 'channels', 'POST /channels/:id/messages (create)', async function () { + var d = await T.apiPost('/channels/' + channelId + '/messages', { + role: 'user', + content: 'ICD integration test message' + }); + T.assertShape(d, T.S.message, 'message'); + msgId = d.id; + }); + + if (msgId) { + await T.test('crud', 'channels', 'GET /channels/:id/messages (all)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/messages'); + var arr = d.messages || d.data; + T.assert(Array.isArray(arr), 'expected array'); + T.assert(arr.length >= 1, 'expected at least 1 message'); + }); + + await T.test('crud', 'channels', 'GET /channels/:id/messages/:id/siblings', async function () { + var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings'); + T.assertHasKey(d, 'siblings', '/siblings'); + T.assert(Array.isArray(d.siblings), 'siblings should be array'); + }); + + // Edit creates sibling + var editedMsgId = null; + await T.test('crud', 'channels', 'POST /channels/:id/messages/:id/edit', async function () { + var d = await T.apiPost('/channels/' + channelId + '/messages/' + msgId + '/edit', { + content: 'ICD edited message content' + }); + T.assertShape(d, T.S.message, 'edited message'); + T.assert(d.id !== msgId, 'edit should create new message id'); + T.assert(d.content === 'ICD edited message content', 'content mismatch'); + editedMsgId = d.id; + }); + + if (editedMsgId) { + await T.test('crud', 'channels', 'GET siblings (after edit, expect 2)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings'); + T.assert(d.siblings.length >= 2, 'edit should create sibling, got ' + d.siblings.length); + }); + + await T.test('crud', 'channels', 'PUT /channels/:id/cursor (switch branch)', async function () { + var d = await T.apiPut('/channels/' + channelId + '/cursor', { + active_leaf_id: msgId + }); + T.assertHasKey(d, 'messages', '/cursor'); + T.assert(Array.isArray(d.messages), 'cursor response should have messages array'); + }); + } + + await T.test('crud', 'channels', 'GET /messages/:id/files', async function () { + var d = await T.apiGet('/messages/' + msgId + '/files'); + T.assertHasKey(d, 'files', '/message-files'); + T.assert(Array.isArray(d.files), 'files should be array'); + }); + + await T.test('crud', 'channels', 'GET /channels/:id/path (after messages)', async function () { + var d = await T.apiGet('/channels/' + channelId + '/path'); + T.assertHasKey(d, 'messages', '/path'); + T.assert(d.messages.length >= 1, 'path should have messages'); + }); + + await T.test('crud', 'channels', 'POST /channels/:id/generate-title', async function () { + try { + var d = await T.apiPost('/channels/' + channelId + '/generate-title', {}); + T.assert(typeof d === 'object', 'expected object'); + } catch (e) { + if (e.message && (e.message.indexOf('400') !== -1 || e.message.indexOf('502') !== -1 || e.message.indexOf('model') !== -1)) + return; + throw e; + } + }); + } + + // ── Typing indicator ── + await T.test('crud', 'channels', 'POST /channels/:id/typing', async function () { + var d = await T.apiPost('/channels/' + channelId + '/typing', {}); + T.assert(d.ok === true, 'expected { ok: true }'); + }); + + // ── Mark Read ── + await T.test('crud', 'channels', 'POST /channels/:id/mark-read', async function () { + var d = await T.apiPost('/channels/' + channelId + '/mark-read', {}); + T.assert(typeof d === 'object', 'expected object'); + }); + + // ── Delete ── + await T.test('crud', 'channels', 'DELETE /channels/:id', async function () { + await T.safeDelete('/channels/' + channelId); + channelId = null; + }); + } + + // ── DM Creation + Dedup ── + var dmChannelId = null; + var dmUser = T.getFixtureUser ? T.getFixtureUser('-user') : null; + if (dmUser && dmUser.id) { + await T.test('crud', 'channels', 'POST /channels (type=dm, create)', async function () { + var d = await T.apiPost('/channels', { + title: testTag + '-dm', + type: 'dm', + participants: [dmUser.id] + }); + T.assert(d.id, 'DM channel should have id'); + T.assert(d.type === 'dm', 'type should be dm'); + dmChannelId = d.id; + T.registerCleanup(function () { if (dmChannelId) return T.safeDelete('/channels/' + dmChannelId); }); + }); + + if (dmChannelId) { + await T.test('crud', 'channels', 'POST /channels (dm dedup → 200)', async function () { + var d = await T.apiPost('/channels', { + title: testTag + '-dm-dup', + type: 'dm', + participants: [dmUser.id] + }); + T.assert(d.id === dmChannelId, 'dedup should return same channel id'); + }); + + await T.test('crud', 'channels', 'GET /channels?types=dm (type filter)', async function () { + var d = await T.apiGet('/channels?types=dm&per_page=10'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + var found = arr.find(function (ch) { return ch.id === dmChannelId; }); + T.assert(found, 'DM channel should appear in dm type filter'); + }); + + await T.test('crud', 'channels', 'DELETE /channels/:id (dm cleanup)', async function () { + await T.safeDelete('/channels/' + dmChannelId); + dmChannelId = null; + }); + } + } + + // ── Folders CRUD + Channel Assignment ── + var folderId = null; + await T.test('crud', 'channels', 'POST /folders (create)', async function () { + var d = await T.apiPost('/folders', { name: testTag + '-folder', sort_order: 0 }); + var folder = d.folder || d.data || d; + T.assert(folder.id || folder.ID, 'folder response missing id, got keys: ' + Object.keys(folder).join(', ')); + folderId = folder.id || folder.ID; + T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); }); + }); + + if (folderId) { + await T.test('crud', 'channels', 'PUT /folders/:id (update)', async function () { + var d = await T.apiPut('/folders/' + folderId, { name: testTag + '-folder-updated', sort_order: 1 }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // Create a channel, assign to folder, verify filter works + var folderCh = null; + await T.test('crud', 'channels', 'PUT /channels/:id { folder_id } (assign)', async function () { + var ch = await T.apiPost('/channels', { title: testTag + '-folder-test', type: 'direct' }); + folderCh = ch.id; + T.registerCleanup(function () { if (folderCh) return T.safeDelete('/channels/' + folderCh); }); + var d = await T.apiPut('/channels/' + folderCh, { folder_id: folderId }); + T.assert(d.folder_id === folderId, 'folder_id should be set, got: ' + d.folder_id); + }); + + if (folderCh) { + await T.test('crud', 'channels', 'GET /channels?folder_id=... (filter)', async function () { + var d = await T.apiGet('/channels?folder_id=' + folderId + '&per_page=10'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + var found = arr.find(function (ch) { return ch.id === folderCh; }); + T.assert(found, 'channel should appear in folder_id filter'); + }); + + await T.test('crud', 'channels', 'PUT /channels/:id { folder_id: "" } (unbind)', async function () { + var d = await T.apiPut('/channels/' + folderCh, { folder_id: '' }); + T.assert(!d.folder_id, 'folder_id should be cleared'); + }); + + await T.test('crud', 'channels', 'DELETE /channels/:id (folder test cleanup)', async function () { + await T.safeDelete('/channels/' + folderCh); + folderCh = null; + }); + } + + await T.test('crud', 'channels', 'DELETE /folders/:id', async function () { + await T.safeDelete('/folders/' + folderId); + folderId = null; + }); + } + + // ── User Search ── + await T.test('crud', 'channels', 'GET /users/search', async function () { + var d = await T.apiGet('/users/search?q=' + encodeURIComponent(T.user.username.substring(0, 3))); + T.assertHasKey(d, 'users', '/users/search'); + T.assert(Array.isArray(d.users), 'users should be array'); + }); + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/extensions.js b/surfaces/icd-test-runner/js/crud/extensions.js new file mode 100644 index 0000000..0650de5 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/extensions.js @@ -0,0 +1,192 @@ +/** + * ICD Test Runner — CRUD: Extensions + * Extension install lifecycle (install → list → update → user list → + * tier filter → manifest → tools → assets → settings → disable → + * duplicate 409 → invalid tier 400 → not found 404 → disabled asset 404 → + * delete), system extension disable rejection. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.extensions = async function (testTag) { + + // ── Extensions CRUD ── + + var extId = null; // UUID (DB id) + var extExtId = null; // ext_id (manifest id) + + await T.test('crud', 'extensions', 'POST /admin/extensions (install)', async function () { + extExtId = testTag + '-ext'; + var d = await T.apiPost('/admin/extensions', { + ext_id: extExtId, + name: testTag + ' Test Extension', + version: '1.0.0', + tier: 'browser', + description: 'ICD CRUD test extension', + author: 'icd-runner', + manifest: { _script: 'console.log("icd-test");', tools: [{ name: 'icd_tool', description: 'Test tool' }] }, + is_enabled: true, + is_system: false + }); + T.assertHasKey(d, 'data', 'install response'); + T.assertShape(d.data, T.S.extension, 'installed extension'); + T.assert(d.data.ext_id === extExtId, 'ext_id mismatch'); + T.assert(d.data.tier === 'browser', 'tier should be browser'); + T.assert(d.data.is_enabled === true, 'should be enabled'); + T.assert(d.data.is_system === false, 'should not be system'); + extId = d.data.id; + T.registerCleanup(function () { if (extId) return T.safeDelete('/admin/extensions/' + extId); }); + }); + + if (extId) { + await T.test('crud', 'extensions', 'GET /admin/extensions (list)', async function () { + var d = await T.apiGet('/admin/extensions'); + T.assertHasKey(d, 'data', '/admin/extensions'); + T.assert(Array.isArray(d.data), 'admin list should be array'); + var found = d.data.some(function (e) { return e.id === extId; }); + T.assert(found, 'installed extension should be in admin list'); + }); + + await T.test('crud', 'extensions', 'PUT /admin/extensions/:id (update)', async function () { + var d = await T.apiPut('/admin/extensions/' + extId, { + name: testTag + ' Updated Extension', + version: '2.0.0', + description: 'Updated description' + }); + T.assertHasKey(d, 'data', 'update response'); + T.assert(d.data.name === testTag + ' Updated Extension', 'name not updated'); + T.assert(d.data.version === '2.0.0', 'version not updated'); + }); + + await T.test('crud', 'extensions', 'GET /extensions (user list)', async function () { + var d = await T.apiGet('/extensions'); + T.assertHasKey(d, 'data', '/extensions'); + T.assert(Array.isArray(d.data), 'user list should be array'); + var found = d.data.some(function (e) { return e.id === extId; }); + T.assert(found, 'enabled extension should appear in user list'); + }); + + await T.test('crud', 'extensions', 'GET /extensions?tier=browser (filter)', async function () { + var d = await T.apiGet('/extensions?tier=browser'); + T.assertHasKey(d, 'data', '/extensions?tier=browser'); + var found = d.data.some(function (e) { return e.id === extId; }); + T.assert(found, 'browser extension should appear with tier filter'); + }); + + await T.test('crud', 'extensions', 'GET /extensions?tier=starlark (filter excludes)', async function () { + var d = await T.apiGet('/extensions?tier=starlark'); + T.assertHasKey(d, 'data', '/extensions?tier=starlark'); + var found = d.data.some(function (e) { return e.id === extId; }); + T.assert(!found, 'browser extension should NOT appear with starlark filter'); + }); + + await T.test('crud', 'extensions', 'GET /extensions/:id/manifest (by ext_id)', async function () { + var d = await T.apiGet('/extensions/' + extExtId + '/manifest'); + T.assertHasKey(d, 'data', 'manifest response'); + T.assert(typeof d.data === 'object', 'manifest should be object'); + }); + + await T.test('crud', 'extensions', 'GET /extensions/tools (tool schemas)', async function () { + var d = await T.apiGet('/extensions/tools'); + T.assertHasKey(d, 'data', '/extensions/tools'); + T.assert(Array.isArray(d.data), 'tools should be array'); + var found = d.data.some(function (t) { return t.name === 'icd_tool'; }); + T.assert(found, 'icd_tool should appear in tool schemas'); + }); + + await T.test('crud', 'extensions', 'GET /extensions/:id/assets (public, no auth)', async function () { + var resp = await fetch(T.base + '/api/v1/extensions/' + extExtId + '/assets/main.js'); + T.assert(resp.ok, 'asset fetch should succeed: ' + resp.status); + var text = await resp.text(); + T.assert(text.indexOf('icd-test') !== -1, 'script body should contain icd-test'); + var ct = resp.headers.get('Content-Type') || ''; + T.assert(ct.indexOf('javascript') !== -1, 'content-type should be javascript, got ' + ct); + }); + + await T.test('crud', 'extensions', 'POST /extensions/:id/settings (save)', async function () { + var d = await T.apiPost('/extensions/' + extId + '/settings', { + settings: { theme: 'dark' }, + is_enabled: true + }); + T.assertHasKey(d, 'ok', 'settings response'); + T.assert(d.ok === true, 'ok should be true'); + }); + + await T.test('crud', 'extensions', 'POST /extensions/:id/settings (disable non-system)', async function () { + var d = await T.apiPost('/extensions/' + extId + '/settings', { is_enabled: false }); + T.assert(d.ok === true, 'should succeed for non-system extension'); + // Re-enable for subsequent tests + await T.apiPost('/extensions/' + extId + '/settings', { is_enabled: true }); + }); + + await T.test('crud', 'extensions', 'POST /admin/extensions (duplicate → 409)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'POST', '/admin/extensions', { + ext_id: extExtId, name: 'Duplicate' + }); + T.assert(d._status === 409, 'duplicate ext_id should return 409, got ' + d._status); + }); + + await T.test('crud', 'extensions', 'POST /admin/extensions (invalid tier → 400)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'POST', '/admin/extensions', { + ext_id: 'bad-tier-ext', name: 'Bad Tier', tier: 'invalid' + }); + T.assert(d._status === 400, 'invalid tier should return 400, got ' + d._status); + }); + + await T.test('crud', 'extensions', 'PUT /admin/extensions/:id (not found → 404)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/extensions/00000000-0000-0000-0000-000000000000', { name: 'ghost' }); + T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status); + }); + + await T.test('crud', 'extensions', 'GET /extensions/:id/assets (disabled → 404)', async function () { + // Disable via admin + await T.apiPut('/admin/extensions/' + extId, { is_enabled: false }); + // Cache-bust: the earlier asset fetch is cached (max-age=3600) + var resp = await fetch(T.base + '/api/v1/extensions/' + extExtId + '/assets/main.js?_cb=' + Date.now()); + T.assert(resp.status === 404, 'disabled asset should 404, got ' + resp.status); + // Re-enable + await T.apiPut('/admin/extensions/' + extId, { is_enabled: true }); + }); + + await T.test('crud', 'extensions', 'DELETE /admin/extensions/:id', async function () { + await T.safeDelete('/admin/extensions/' + extId); + // Verify gone from admin list + var d = await T.apiGet('/admin/extensions'); + var found = d.data.some(function (e) { return e.id === extId; }); + T.assert(!found, 'deleted extension should not appear in list'); + extId = null; + }); + } + + // ── System extension disable rejection ── + + var sysExtId = null; + await T.test('crud', 'extensions', 'POST /admin/extensions (system ext)', async function () { + var d = await T.apiPost('/admin/extensions', { + ext_id: testTag + '-sys-ext', name: 'System Test', is_system: true, is_enabled: true + }); + T.assertHasKey(d, 'data', 'system install'); + T.assert(d.data.is_system === true, 'should be system'); + sysExtId = d.data.id; + T.registerCleanup(function () { if (sysExtId) return T.safeDelete('/admin/extensions/' + sysExtId); }); + }); + + if (sysExtId) { + await T.test('crud', 'extensions', 'POST /extensions/:id/settings (system disable → 403)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'POST', + '/extensions/' + sysExtId + '/settings', { is_enabled: false }); + T.assert(d._status === 403, 'disabling system ext should return 403, got ' + d._status); + }); + + await T.test('crud', 'extensions', 'DELETE /admin/extensions/:id (system cleanup)', async function () { + await T.safeDelete('/admin/extensions/' + sysExtId); + sysExtId = null; + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/knowledge.js b/surfaces/icd-test-runner/js/crud/knowledge.js new file mode 100644 index 0000000..86fe6d7 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/knowledge.js @@ -0,0 +1,238 @@ +/** + * ICD Test Runner — CRUD: Knowledge Bases + * KB lifecycle — create, read, update, documents, search, discoverable, + * channel binding, scope auth, cross-user isolation, delete. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.knowledge = async function (testTag) { + + // ── KB CRUD ── + var kbId = null; + await T.test('crud', 'knowledge', 'POST /knowledge-bases (create)', async function () { + var d = await T.apiPost('/knowledge-bases', { + name: testTag + '-kb', + description: 'ICD integration test KB', + scope: 'personal' + }); + T.assertShape(d, T.S.kb, 'kb'); + T.assert(d.scope === 'personal', 'scope should be personal'); + T.assert(d.status === 'active', 'status should be active'); + T.assert(d.document_count === 0, 'new KB should have 0 documents'); + T.assert(d.chunk_count === 0, 'new KB should have 0 chunks'); + kbId = d.id; + T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); }); + }); + + if (!kbId) return; + + await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (read)', async function () { + var d = await T.apiGet('/knowledge-bases/' + kbId); + T.assertShape(d, T.S.kb, 'kb'); + T.assert(d.id === kbId, 'id mismatch'); + }); + + await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (update)', async function () { + var newName = testTag + '-kb-updated'; + var d = await T.apiPut('/knowledge-bases/' + kbId, { name: newName }); + T.assertShape(d, T.S.kb, 'kb-update'); + T.assert(d.name === newName, 'name should have updated, got: ' + d.name); + }); + + await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (empty body → 400)', async function () { + var token = await T.getAuthToken(); + var resp = await T.authFetch(token, 'PUT', '/knowledge-bases/' + kbId, {}); + T.assert(resp._status === 400, 'expected 400 for empty update, got ' + resp._status); + }); + + // ── Documents ── + await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (empty)', async function () { + var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents'); + T.assertHasKey(d, 'data', '/kb-documents'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(d.data.length === 0, 'new KB should have 0 documents'); + }); + + // Document upload → status → delete (requires object store) + var docId = null; + var storageOk = false; + try { + var ss = await T.apiGet('/admin/storage/status'); + storageOk = ss && ss.configured === true; + } catch (e) { /* non-admin or storage not configured */ } + + if (storageOk) { + await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/documents (upload)', async function () { + var blob = new Blob(['# ICD Test Document\n\nThis is test content for KB ingestion.'], { type: 'text/markdown' }); + try { + var d = await T.apiUpload('/knowledge-bases/' + kbId + '/documents', blob, 'icd-test.md'); + T.assert(d.id, 'expected document id'); + T.assert(d.filename === 'icd-test.md', 'filename mismatch: ' + d.filename); + T.assert(d.status === 'pending', 'initial status should be pending, got: ' + d.status); + T.assert(d.kb_id === kbId, 'kb_id mismatch'); + docId = d.id; + } catch (e) { + // 412 = no embedding model role configured (infrastructure, not contract bug) + if (e.message && (e.message.indexOf('412') !== -1 || e.message.indexOf('embedding') !== -1)) return; + throw e; + } + }); + + if (docId) { + await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents/:docId/status', async function () { + var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents/' + docId + '/status'); + T.assert(d.id === docId, 'doc id mismatch'); + T.assert(typeof d.status === 'string', 'status should be string'); + T.assert(typeof d.filename === 'string', 'filename should be string'); + }); + + await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (has doc)', async function () { + var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents'); + T.assertHasKey(d, 'data', '/kb-documents'); + T.assert(d.data.length >= 1, 'should have at least 1 document'); + }); + + await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id/documents/:docId', async function () { + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1/knowledge-bases/' + kbId + '/documents/' + docId, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }); + T.assert(resp.ok, 'delete doc should succeed, got ' + resp.status); + var body = await resp.json(); + T.assert(body.deleted === true, 'deleted should be true'); + docId = null; + }); + + await T.test('crud', 'knowledge', 'GET /documents/:docId/status (deleted → 404)', async function () { + var token = await T.getAuthToken(); + var resp = await T.authFetch(token, 'GET', + '/knowledge-bases/' + kbId + '/documents/00000000-0000-0000-0000-000000000099/status'); + T.assert(resp._status === 404, 'expected 404 for deleted doc, got ' + resp._status); + }); + } + } + + // ── Search ── + await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/search', async function () { + try { + var d = await T.apiPost('/knowledge-bases/' + kbId + '/search', { + query: 'test', limit: 5 + }); + // Response envelope uses "data" key (not "results") + T.assertHasKey(d, 'data', '/kb-search'); + T.assertHasKey(d, 'query', '/kb-search'); + T.assertHasKey(d, 'total', '/kb-search'); + T.assert(Array.isArray(d.data), 'data should be array'); + } catch (e) { + // "failed to embed query" = no embedding model configured (infrastructure, not contract bug) + if (e.message && e.message.indexOf('embed') !== -1) return; + throw e; + } + }); + + // ── Rebuild (no documents = 400, or no ingester = 503) ── + await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/rebuild (empty → 400|503)', async function () { + var token = await T.getAuthToken(); + var resp = await T.authFetch(token, 'POST', '/knowledge-bases/' + kbId + '/rebuild', {}); + T.assert(resp._status === 400 || resp._status === 503, + 'expected 400 (no docs) or 503 (no ingester), got ' + resp._status); + }); + + // ── Channel KB Binding ── + var bindChannelId = null; + await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (bind)', async function () { + // Create a temp channel + var ch = await T.apiPost('/channels', { title: testTag + '-kb-bind' }); + bindChannelId = ch.id; + T.registerCleanup(function () { if (bindChannelId) return T.safeDelete('/channels/' + bindChannelId); }); + + // Bind KB + var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', { + kb_ids: [kbId] + }); + T.assertHasKey(d, 'data', '/channel-kb-set'); + }); + + if (bindChannelId) { + await T.test('crud', 'knowledge', 'GET /channels/:id/knowledge-bases (verify)', async function () { + var d = await T.apiGet('/channels/' + bindChannelId + '/knowledge-bases'); + T.assertHasKey(d, 'data', '/channel-kbs'); + T.assert(d.data.length === 1, 'should have 1 linked KB, got ' + d.data.length); + var linked = d.data[0]; + T.assert(linked.kb_id === kbId, 'linked kb_id mismatch'); + T.assert(typeof linked.kb_name === 'string', 'kb_name should be string'); + T.assert(typeof linked.enabled === 'boolean', 'enabled should be boolean'); + }); + + await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (unbind)', async function () { + var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', { kb_ids: [] }); + T.assertHasKey(d, 'data', '/channel-kb-unbind'); + T.assert(d.data.length === 0, 'should have 0 linked KBs after unbind'); + }); + } + + // ── Discoverable ── + await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id/discoverable (toggle)', async function () { + var d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: false }); + T.assert(d.status === 'ok', 'expected status ok'); + + // Toggle back + d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: true }); + T.assert(d.status === 'ok', 'expected status ok on re-enable'); + }); + + // ── Cross-user isolation (requires fixtures) ── + var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null; + if (fixtureUser && fixtureUser.token) { + await T.test('crud', 'knowledge', 'isolation: other user cannot access personal KB', async function () { + var resp = await T.authFetch(fixtureUser.token, 'GET', '/knowledge-bases/' + kbId); + // Personal KB of admin should be hidden (404) from other user + T.assert(resp._status === 404, 'expected 404 for other user, got ' + resp._status); + }); + + await T.test('crud', 'knowledge', 'isolation: other user cannot delete personal KB', async function () { + var resp = await T.authFetch(fixtureUser.token, 'DELETE', '/knowledge-bases/' + kbId); + T.assert(resp._status === 404, 'expected 404 for delete by other user, got ' + resp._status); + }); + + await T.test('crud', 'knowledge', 'scope: non-admin global create → 403', async function () { + var resp = await T.authFetch(fixtureUser.token, 'POST', '/knowledge-bases', { + name: testTag + '-sneaky-global', scope: 'global' + }); + T.assert(resp._status === 403, 'expected 403 for non-admin global KB, got ' + resp._status); + }); + + await T.test('crud', 'knowledge', 'discoverable: other user cannot toggle', async function () { + var resp = await T.authFetch(fixtureUser.token, 'PUT', + '/knowledge-bases/' + kbId + '/discoverable', { discoverable: false }); + T.assert(resp._status === 404 || resp._status === 403, + 'expected 403/404 for non-owner toggle, got ' + resp._status); + }); + } + + // ── Delete ── + await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id', async function () { + await T.safeDelete('/knowledge-bases/' + kbId); + kbId = null; + }); + + await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (deleted → 404)', async function () { + var token = await T.getAuthToken(); + var resp = await T.authFetch(token, 'GET', '/knowledge-bases/00000000-0000-0000-0000-000000000099'); + T.assert(resp._status === 404, 'expected 404 for nonexistent KB, got ' + resp._status); + }); + + // Cleanup temp channel + if (bindChannelId) { + try { await T.safeDelete('/channels/' + bindChannelId); } catch (e) { /* ok */ } + bindChannelId = null; + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/memory.js b/surfaces/icd-test-runner/js/crud/memory.js new file mode 100644 index 0000000..4cb5b37 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/memory.js @@ -0,0 +1,92 @@ +/** + * ICD Test Runner — CRUD: Memory + * Memory listing, count shape, error paths, admin endpoints. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.memory = async function (testTag) { + + // ── Memory CRUD ── + // No REST POST for memories (created by AI tools / background extraction). + // Test error paths, count shape, and admin endpoints. + var fakeMemId = '00000000-0000-0000-0000-000000000000'; + + await T.test('crud', 'memory', 'GET /memories (list, envelope shape)', async function () { + var d = await T.apiGet('/memories'); + T.assertHasKey(d, 'data', '/memories'); + T.assert(Array.isArray(d.data), 'data should be array'); + // If memories exist, validate shape + if (d.data.length > 0) { + T.assertShape(d.data[0], T.S.memory, 'memory[0]'); + } + }); + + await T.test('crud', 'memory', 'GET /memories?status=pending_review', async function () { + var d = await T.apiGet('/memories?status=pending_review'); + T.assertHasKey(d, 'data', '/memories?status=pending_review'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('crud', 'memory', 'GET /memories/count (shape)', async function () { + var d = await T.apiGet('/memories/count'); + T.assertHasKey(d, 'active', '/memories/count'); + T.assertHasKey(d, 'pending', '/memories/count'); + T.assert(typeof d.active === 'number', 'active should be number'); + T.assert(typeof d.pending === 'number', 'pending should be number'); + T.assert(d.active >= 0, 'active should be >= 0'); + T.assert(d.pending >= 0, 'pending should be >= 0'); + }); + + await T.test('crud', 'memory', 'PUT /memories/:id (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'PUT', '/memories/' + fakeMemId, { value: 'x' }); + T.assertStatus(d, 404, 'update non-existent memory'); + }); + + await T.test('crud', 'memory', 'DELETE /memories/:id (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'DELETE', '/memories/' + fakeMemId); + T.assertStatus(d, 404, 'delete non-existent memory'); + }); + + await T.test('crud', 'memory', 'POST /memories/:id/approve (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/approve'); + T.assertStatus(d, 404, 'approve non-existent memory'); + }); + + await T.test('crud', 'memory', 'POST /memories/:id/reject (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/reject'); + T.assertStatus(d, 404, 'reject non-existent memory'); + }); + + if (T.user.role === 'admin') { + await T.test('crud', 'memory', 'GET /admin/memories/pending (admin)', async function () { + var d = await T.apiGet('/admin/memories/pending'); + T.assertHasKey(d, 'data', '/admin/memories/pending'); + T.assert(Array.isArray(d.data), 'data should be array'); + if (d.data.length > 0) { + T.assertShape(d.data[0], T.S.memory, 'pending[0]'); + } + }); + + await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (empty ids)', async function () { + var d = await T.apiPost('/admin/memories/bulk-approve', { ids: [] }); + T.assertHasKey(d, 'approved', 'bulk-approve'); + T.assert(d.approved === 0, 'empty ids should approve 0'); + }); + + await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (bad body → 400)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/admin/memories/bulk-approve', 'not-json'); + T.assertStatus(d, 400, 'bad body'); + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/models.js b/surfaces/icd-test-runner/js/crud/models.js new file mode 100644 index 0000000..b274fff --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/models.js @@ -0,0 +1,116 @@ +/** + * ICD Test Runner — CRUD: Models + * Model preference lifecycle — hide/unhide, bulk, validation. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.models = async function (testTag) { + + // ── Model Preferences CRUD ── + // Depends on provider setup from smoke tier — needs at least one model in /models/enabled + + await T.test('crud', 'models', 'GET /models/preferences (initially empty for test user)', async function () { + var d = await T.apiGet('/models/preferences'); + T.assertHasKey(d, 'data', '/models/preferences'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // Find a model to use for preference tests + var prefModelId = null; + var prefConfigId = null; + await T.test('crud', 'models', 'GET /models/enabled (pick model for pref test)', async function () { + var d = await T.apiGet('/models/enabled'); + var models = d.data || []; + var catalog = models.filter(function (m) { return !m.is_persona && m.provider_config_id; }); + if (catalog.length === 0) return; // no catalog models yet — skip pref write tests + prefModelId = catalog[0].model_id; + prefConfigId = catalog[0].provider_config_id; + }); + + if (prefModelId && prefConfigId) { + await T.test('crud', 'models', 'PUT /models/preferences (hide model)', async function () { + var d = await T.apiPut('/models/preferences', { + model_id: prefModelId, + provider_config_id: prefConfigId, + hidden: true + }); + T.assert(d.message === 'preference updated', 'expected success message'); + }); + + await T.test('crud', 'models', 'GET /models/preferences (verify hidden)', async function () { + var d = await T.apiGet('/models/preferences'); + var prefs = d.data || []; + var found = prefs.find(function (p) { + return p.model_id === prefModelId && p.provider_config_id === prefConfigId; + }); + T.assert(found, 'preference entry should exist after PUT'); + T.assert(found.hidden === true, 'hidden should be true'); + T.assertShape(found, T.S.modelPreference, 'preference'); + }); + + await T.test('crud', 'models', 'GET /models/enabled (hidden flag propagated)', async function () { + var d = await T.apiGet('/models/enabled'); + var models = d.data || []; + var found = models.find(function (m) { + return m.model_id === prefModelId && m.provider_config_id === prefConfigId; + }); + if (found) { + T.assert(found.hidden === true, 'model should have hidden=true in /models/enabled'); + } + }); + + await T.test('crud', 'models', 'PUT /models/preferences (unhide — upsert)', async function () { + var d = await T.apiPut('/models/preferences', { + model_id: prefModelId, + provider_config_id: prefConfigId, + hidden: false + }); + T.assert(d.message === 'preference updated', 'expected success message'); + }); + + await T.test('crud', 'models', 'GET /models/preferences (verify unhidden, no dup)', async function () { + var d = await T.apiGet('/models/preferences'); + var prefs = d.data || []; + var matches = prefs.filter(function (p) { + return p.model_id === prefModelId && p.provider_config_id === prefConfigId; + }); + T.assert(matches.length === 1, 'upsert should not duplicate: got ' + matches.length); + T.assert(matches[0].hidden === false, 'hidden should be false after unhide'); + }); + + await T.test('crud', 'models', 'PUT /models/preferences (missing provider_config_id → 400)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'PUT', '/models/preferences', { model_id: prefModelId, hidden: true }); + T.assertStatus(d, 400, 'missing provider_config_id'); + }); + + await T.test('crud', 'models', 'PUT /models/preferences (missing model_id → 400)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'PUT', '/models/preferences', { provider_config_id: prefConfigId, hidden: true }); + T.assertStatus(d, 400, 'missing model_id'); + }); + + await T.test('crud', 'models', 'POST /models/preferences/bulk (hide)', async function () { + var d = await T.apiPost('/models/preferences/bulk', { + entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }], + hidden: true + }); + T.assert(d.message === 'preferences updated', 'expected bulk success message'); + T.assert(d.count === 1, 'count should be 1'); + }); + + await T.test('crud', 'models', 'POST /models/preferences/bulk (unhide cleanup)', async function () { + var d = await T.apiPost('/models/preferences/bulk', { + entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }], + hidden: false + }); + T.assert(d.message === 'preferences updated', 'expected bulk success message'); + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/notes.js b/surfaces/icd-test-runner/js/crud/notes.js new file mode 100644 index 0000000..94b1844 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/notes.js @@ -0,0 +1,64 @@ +/** + * ICD Test Runner — CRUD: Notes + * Note lifecycle — create, read, update, search, backlinks, delete. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.notes = async function (testTag) { + + // ── Notes CRUD ── + var noteId = null; + await T.test('crud', 'notes', 'POST /notes (create)', async function () { + var d = await T.apiPost('/notes', { + title: testTag + '-note', + content: '# ICD Test Note\n\nTest content with [[wikilink]].', + folder: 'icd-test' + }); + T.assertShape(d, T.S.note, 'note'); + noteId = d.id; + T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); }); + }); + + if (noteId) { + await T.test('crud', 'notes', 'GET /notes/:id (read)', async function () { + var d = await T.apiGet('/notes/' + noteId); + T.assertShape(d, T.S.note, 'note'); + T.assert(d.id === noteId, 'id mismatch'); + T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch'); + }); + + await T.test('crud', 'notes', 'PUT /notes/:id (update)', async function () { + var d = await T.apiPut('/notes/' + noteId, { + title: testTag + '-note-updated', + content: '# Updated\n\nNew content.' + }); + T.assert(d.title === testTag + '-note-updated' || (d.id && d.id === noteId), 'update response'); + }); + + await T.test('crud', 'notes', 'GET /notes/search', async function () { + var d = await T.apiGet('/notes/search?q=' + encodeURIComponent(testTag)); + T.assertHasKey(d, 'data', '/notes/search'); + }); + + await T.test('crud', 'notes', 'GET /notes/search-titles', async function () { + var d = await T.apiGet('/notes/search-titles?q=' + encodeURIComponent(testTag)); + T.assertHasKey(d, 'data', '/notes/search-titles'); + }); + + await T.test('crud', 'notes', 'GET /notes/:id/backlinks', async function () { + var d = await T.apiGet('/notes/' + noteId + '/backlinks'); + T.assertHasKey(d, 'data', '/backlinks'); + }); + + await T.test('crud', 'notes', 'DELETE /notes/:id', async function () { + await T.safeDelete('/notes/' + noteId); + noteId = null; + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/notifications.js b/surfaces/icd-test-runner/js/crud/notifications.js new file mode 100644 index 0000000..f8171be --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/notifications.js @@ -0,0 +1,128 @@ +/** + * ICD Test Runner — CRUD: Notifications + * Full preference lifecycle + notification endpoint error paths. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.notifications = async function (testTag) { + + // ── Preference: Set ── + await T.test('crud', 'notifications', 'PUT /notifications/preferences/:type (set)', async function () { + var d = await T.apiPut('/notifications/preferences/kb.ready', { + in_app: true, email: true + }); + T.assert(typeof d === 'object', 'expected object'); + T.assert(d.type === 'kb.ready', 'type should be kb.ready, got: ' + d.type); + T.assert(d.in_app === true, 'in_app should be true'); + T.assert(d.email === true, 'email should be true'); + }); + + // ── Preference: Read back via list ── + await T.test('crud', 'notifications', 'GET /notifications/preferences (round-trip)', async function () { + var d = await T.apiGet('/notifications/preferences'); + T.assertHasKey(d, 'data', 'preferences envelope'); + T.assert(Array.isArray(d.data), 'data should be array'); + var found = d.data.find(function (p) { return p.type === 'kb.ready'; }); + T.assert(found, 'kb.ready preference should be in list'); + T.assert(found.in_app === true, 'in_app should be true'); + T.assert(found.email === true, 'email should be true'); + }); + + // ── Preference: Partial update ── + await T.test('crud', 'notifications', 'PUT /notifications/preferences/:type (partial update)', async function () { + var d = await T.apiPut('/notifications/preferences/kb.ready', { + email: false + }); + T.assert(d.in_app === true, 'in_app should remain true after partial update'); + T.assert(d.email === false, 'email should be false after partial update'); + }); + + // ── Preference: Wildcard ── + await T.test('crud', 'notifications', 'PUT /notifications/preferences/* (wildcard)', async function () { + var d = await T.apiPut('/notifications/preferences/*', { + in_app: false, email: true + }); + T.assert(d.type === '*', 'type should be *, got: ' + d.type); + T.assert(d.in_app === false, 'in_app should be false'); + T.assert(d.email === true, 'email should be true'); + }); + + // ── Preference: Delete + verify removed ── + await T.test('crud', 'notifications', 'DELETE /notifications/preferences/:type (reset)', async function () { + await T.safeDelete('/notifications/preferences/kb.ready'); + var d = await T.apiGet('/notifications/preferences'); + var found = (d.data || []).find(function (p) { return p.type === 'kb.ready'; }); + T.assert(!found, 'kb.ready should be removed after delete'); + }); + + // ── Preference: Idempotent delete ── + await T.test('crud', 'notifications', 'DELETE /notifications/preferences/:type (idempotent)', async function () { + await T.safeDelete('/notifications/preferences/nonexistent.type'); + }); + + // ── Cleanup wildcard preference ── + await T.test('crud', 'notifications', 'DELETE /notifications/preferences/* (cleanup)', async function () { + await T.safeDelete('/notifications/preferences/*'); + }); + + // ── Notification list: envelope shape ── + await T.test('crud', 'notifications', 'GET /notifications (envelope shape)', async function () { + var d = await T.apiGet('/notifications'); + T.assertHasKey(d, 'data', 'notifications envelope'); + T.assertHasKey(d, 'total', 'notifications total'); + T.assertHasKey(d, 'limit', 'notifications limit'); + T.assertHasKey(d, 'offset', 'notifications offset'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(typeof d.total === 'number', 'total should be number'); + T.assert(typeof d.limit === 'number', 'limit should be number'); + T.assert(typeof d.offset === 'number', 'offset should be number'); + }); + + // ── Notification list: pagination params ── + await T.test('crud', 'notifications', 'GET /notifications?limit=2 (pagination)', async function () { + var d = await T.apiGet('/notifications?limit=2&offset=0'); + T.assert(d.limit === 2, 'limit should be 2, got: ' + d.limit); + T.assert(d.offset === 0, 'offset should be 0, got: ' + d.offset); + }); + + // ── Notification list: unread_only filter ── + await T.test('crud', 'notifications', 'GET /notifications?unread_only=true', async function () { + var d = await T.apiGet('/notifications?unread_only=true'); + T.assertHasKey(d, 'data', 'unread envelope'); + T.assert(Array.isArray(d.data), 'unread data should be array'); + }); + + // ── Unread count: shape ── + await T.test('crud', 'notifications', 'GET /notifications/unread-count (shape)', async function () { + var d = await T.apiGet('/notifications/unread-count'); + T.assertHasKey(d, 'count', 'unread-count'); + T.assert(typeof d.count === 'number', 'count should be number'); + }); + + // ── Mark all read: no-op on empty ── + await T.test('crud', 'notifications', 'POST /notifications/mark-all-read (no-op)', async function () { + var d = await T.apiPost('/notifications/mark-all-read'); + T.assertHasKey(d, 'ok', 'mark-all-read'); + T.assert(d.ok === true, 'ok should be true'); + }); + + // ── Mark read: non-existent → 404 ── + await T.test('crud', 'notifications', 'PATCH /notifications/:id/read (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'PATCH', '/notifications/00000000-0000-0000-0000-000000000000/read'); + T.assertStatus(d, 404, 'mark read non-existent'); + }); + + // ── Delete: non-existent → 404 ── + await T.test('crud', 'notifications', 'DELETE /notifications/:id (not found → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'DELETE', '/notifications/00000000-0000-0000-0000-000000000000'); + T.assertStatus(d, 404, 'delete non-existent'); + }); + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/personas.js b/surfaces/icd-test-runner/js/crud/personas.js new file mode 100644 index 0000000..0078291 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/personas.js @@ -0,0 +1,247 @@ +/** + * ICD Test Runner — CRUD: Personas + * Admin persona lifecycle (create → list → update → tool grants → + * KB bindings → delete), personal persona (policy-gated), team persona + * (fixture team), persona groups CRUD. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.personas = async function (testTag) { + + // ── Personas CRUD (admin + personal + team + tool grants + groups) ── + var personaId = ''; + var personalPersonaId = ''; + var teamPersonaId = ''; + + // Admin persona CRUD + await T.test('crud', 'personas', 'POST /admin/personas (create global)', async function () { + var d = await T.apiPost('/admin/personas', { + name: 'ICD Test Persona', base_model_id: 'test-model', + system_prompt: 'You are a test persona.', description: 'Created by ICD test runner' + }); + T.assert(d.id, 'persona should have id'); + T.assert(d.scope === 'global', 'admin persona scope should be global'); + T.assert(d.handle, 'handle should be auto-generated'); + personaId = d.id; + }); + + if (personaId) { + await T.test('crud', 'personas', 'GET /admin/personas (list contains new)', async function () { + var d = await T.apiGet('/admin/personas'); + T.assertHasKey(d, 'data', '/admin/personas'); + var found = d.data.some(function (p) { return p.id === personaId; }); + T.assert(found, 'created persona should appear in admin list'); + }); + + await T.test('crud', 'personas', 'PUT /admin/personas/:id (update)', async function () { + var d = await T.apiPut('/admin/personas/' + personaId, { name: 'ICD Updated Persona' }); + T.assert(d.message === 'persona updated', 'expected update confirmation'); + }); + + // Tool grants + await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (empty)', async function () { + var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants'); + T.assertHasKey(d, 'data', 'tool-grants'); + T.assert(Array.isArray(d.data), 'grants should be array'); + T.assert(d.data.length === 0, 'initial grants should be empty'); + }); + + await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (set)', async function () { + var d = await T.apiPut('/admin/personas/' + personaId + '/tool-grants', { + tool_names: ['web_search', 'calculator'] + }); + T.assert(d.status === 'ok', 'expected ok'); + }); + + await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (verify)', async function () { + var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants'); + T.assert(d.data.length === 2, 'should have 2 grants, got ' + d.data.length); + }); + + await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (clear)', async function () { + await T.apiPut('/admin/personas/' + personaId + '/tool-grants', { tool_names: [] }); + var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants'); + T.assert(d.data.length === 0, 'grants should be empty after clear'); + }); + + // KB bindings + await T.test('crud', 'personas', 'GET /admin/personas/:id/knowledge-bases (empty)', async function () { + var d = await T.apiGet('/admin/personas/' + personaId + '/knowledge-bases'); + T.assertHasKey(d, 'data', 'persona-kbs'); + T.assert(Array.isArray(d.data), 'kbs should be array'); + }); + + await T.test('crud', 'personas', 'DELETE /admin/personas/:id', async function () { + await T.apiDelete('/admin/personas/' + personaId); + }); + + await T.test('crud', 'personas', 'GET /admin/personas (deleted gone)', async function () { + var d = await T.apiGet('/admin/personas'); + var found = d.data.some(function (p) { return p.id === personaId; }); + T.assert(!found, 'deleted persona should not appear in list'); + }); + } + + // Personal persona (policy-gated) + await T.test('crud', 'personas', 'POST /personas (create personal)', async function () { + // Ensure policy allows personal personas + try { await T.apiPut('/admin/settings/allow_user_personas', { value: 'true' }); } catch (e) { /* may already be set */ } + var d = await T.apiPost('/personas', { + name: 'ICD Personal Bot', base_model_id: 'test-model', + system_prompt: 'Personal test.' + }); + T.assert(d.id, 'personal persona should have id'); + T.assert(d.scope === 'personal', 'scope should be personal'); + personalPersonaId = d.id; + }); + + if (personalPersonaId) { + await T.test('crud', 'personas', 'PUT /personas/:id (update personal)', async function () { + var d = await T.apiPut('/personas/' + personalPersonaId, { description: 'updated desc' }); + T.assert(d.message === 'persona updated', 'expected update confirmation'); + }); + + await T.test('crud', 'personas', 'GET /personas (list contains personal)', async function () { + var d = await T.apiGet('/personas'); + T.assertHasKey(d, 'data', '/personas'); + var found = d.data.some(function (p) { return p.id === personalPersonaId; }); + T.assert(found, 'personal persona should appear in user list'); + }); + + await T.test('crud', 'personas', 'DELETE /personas/:id (delete personal)', async function () { + await T.apiDelete('/personas/' + personalPersonaId); + }); + } + + // Team persona (uses fixture team) + if (T.fixtures.team) { + var tId = T.fixtures.team.id; + + await T.test('crud', 'personas', 'POST /teams/:teamId/personas (create)', async function () { + var d = await T.apiPost('/teams/' + tId + '/personas', { + name: 'ICD Team Bot', base_model_id: 'test-model', + system_prompt: 'Team test.' + }); + T.assert(d.id, 'team persona should have id'); + T.assert(d.scope === 'team', 'scope should be team'); + teamPersonaId = d.id; + }); + + if (teamPersonaId) { + await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id (update)', async function () { + var d = await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId, { name: 'ICD Team Bot v2' }); + T.assert(d.message === 'persona updated', 'expected update confirmation'); + }); + + await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/tool-grants', async function () { + var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants'); + T.assertHasKey(d, 'data', 'team-tool-grants'); + T.assert(Array.isArray(d.data), 'grants should be array'); + }); + + await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id/tool-grants (set)', async function () { + await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants', { + tool_names: ['kb_search'] + }); + var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants'); + T.assert(d.data.length === 1, 'team persona should have 1 grant'); + }); + + await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/knowledge-bases', async function () { + var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/knowledge-bases'); + T.assertHasKey(d, 'data', 'team-persona-kbs'); + }); + + await T.test('crud', 'personas', 'DELETE /teams/:teamId/personas/:id', async function () { + await T.apiDelete('/teams/' + tId + '/personas/' + teamPersonaId); + }); + + await T.test('crud', 'personas', 'GET /teams/:teamId/personas (deleted gone)', async function () { + var d = await T.apiGet('/teams/' + tId + '/personas'); + var found = d.data.some(function (p) { return p.id === teamPersonaId; }); + T.assert(!found, 'deleted team persona should not appear in list'); + }); + } + } + + // Persona Groups CRUD + var pgId = ''; + var pgMemberId = ''; + + await T.test('crud', 'personas', 'POST /persona-groups (create)', async function () { + var d = await T.apiPost('/persona-groups', { name: 'ICD Test Group', description: 'Test' }); + T.assert(d.id, 'group should have id'); + T.assert(d.scope === 'personal', 'group scope should be personal'); + pgId = d.id; + }); + + if (pgId) { + await T.test('crud', 'personas', 'GET /persona-groups/:id (read)', async function () { + var d = await T.apiGet('/persona-groups/' + pgId); + T.assert(d.id === pgId, 'group id should match'); + T.assert(d.name === 'ICD Test Group', 'group name should match'); + T.assert(Array.isArray(d.members), 'members should be array'); + }); + + await T.test('crud', 'personas', 'PUT /persona-groups/:id (update)', async function () { + var d = await T.apiPut('/persona-groups/' + pgId, { name: 'ICD Updated Group' }); + T.assert(d.ok === true, 'expected ok'); + }); + + await T.test('crud', 'personas', 'GET /persona-groups (list)', async function () { + var d = await T.apiGet('/persona-groups'); + T.assertHasKey(d, 'data', '/persona-groups'); + var found = d.data.some(function (g) { return g.id === pgId; }); + T.assert(found, 'created group should appear in list'); + }); + + // Add a member (need a persona — create a temporary one) + var tmpPersonaId = ''; + await T.test('crud', 'personas', 'POST /admin/personas (for group member)', async function () { + var d = await T.apiPost('/admin/personas', { + name: 'Group Member Bot', base_model_id: 'test-model' + }); + tmpPersonaId = d.id; + }); + + if (tmpPersonaId) { + await T.test('crud', 'personas', 'POST /persona-groups/:id/members (add)', async function () { + var d = await T.apiPost('/persona-groups/' + pgId + '/members', { + persona_id: tmpPersonaId, is_leader: true + }); + T.assert(d.ok === true, 'expected ok'); + }); + + await T.test('crud', 'personas', 'GET /persona-groups/:id (with member)', async function () { + var d = await T.apiGet('/persona-groups/' + pgId); + T.assert(d.members.length === 1, 'should have 1 member'); + T.assert(d.members[0].is_leader === true, 'member should be leader'); + pgMemberId = d.members[0].id; + }); + + if (pgMemberId) { + await T.test('crud', 'personas', 'DELETE /persona-groups/:id/members/:mid', async function () { + await T.apiDelete('/persona-groups/' + pgId + '/members/' + pgMemberId); + }); + + await T.test('crud', 'personas', 'GET /persona-groups/:id (member removed)', async function () { + var d = await T.apiGet('/persona-groups/' + pgId); + T.assert(d.members.length === 0, 'should have 0 members after removal'); + }); + } + + // Cleanup temp persona + try { await T.apiDelete('/admin/personas/' + tmpPersonaId); } catch (e) { /* ok */ } + } + + await T.test('crud', 'personas', 'DELETE /persona-groups/:id', async function () { + await T.apiDelete('/persona-groups/' + pgId); + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/profile.js b/surfaces/icd-test-runner/js/crud/profile.js new file mode 100644 index 0000000..55c8e30 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/profile.js @@ -0,0 +1,99 @@ +/** + * ICD Test Runner — CRUD: Profile & Settings + * Profile update, settings merge round-trip, avatar lifecycle. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.profile = async function (testTag) { + + // ── GET /profile — full shape (augments smoke) ── + var originalProfile = null; + await T.test('crud', 'profile', 'GET /profile (full shape)', async function () { + var d = await T.apiGet('/profile'); + T.assertShape(d, T.S.profile, 'profile'); + T.assert(typeof d.email === 'string' && d.email.length > 0, 'email must be non-empty string'); + T.assert(typeof d.settings === 'object' && d.settings !== null, 'settings must be object (not null)'); + originalProfile = d; + }); + + // ── PUT /profile — update display_name ── + if (originalProfile) { + await T.test('crud', 'profile', 'PUT /profile (display_name)', async function () { + var newName = testTag + '-display'; + var d = await T.apiPut('/profile', { display_name: newName }); + T.assertShape(d, T.S.profile, 'profile-after-update'); + T.assert(d.display_name === newName, 'display_name not updated: ' + d.display_name); + }); + + // Restore original display_name + await T.test('crud', 'profile', 'PUT /profile (restore display_name)', async function () { + var d = await T.apiPut('/profile', { display_name: originalProfile.display_name || '' }); + T.assertShape(d, T.S.profile, 'profile-restored'); + }); + } + + // ── GET /settings — envelope with empty object for clean user ── + await T.test('crud', 'profile', 'GET /settings (envelope)', async function () { + var d = await T.apiGet('/settings'); + T.assertHasKey(d, 'settings', 'GET /settings'); + T.assert(typeof d.settings === 'object' && d.settings !== null, 'settings must be object'); + }); + + // ── PUT /settings — create, then merge, then verify ── + await T.test('crud', 'profile', 'PUT /settings (initial)', async function () { + var d = await T.apiPut('/settings', { + icd_test_key: 'alpha', + icd_test_other: 'keep-me' + }); + T.assertHasKey(d, 'settings', 'PUT /settings response'); + T.assert(d.settings.icd_test_key === 'alpha', 'icd_test_key should be alpha'); + T.assert(d.settings.icd_test_other === 'keep-me', 'icd_test_other should be set'); + }); + + await T.test('crud', 'profile', 'PUT /settings (merge overwrites + preserves)', async function () { + var d = await T.apiPut('/settings', { icd_test_key: 'beta' }); + T.assertHasKey(d, 'settings', 'PUT /settings merge'); + T.assert(d.settings.icd_test_key === 'beta', 'icd_test_key should be overwritten to beta'); + T.assert(d.settings.icd_test_other === 'keep-me', 'icd_test_other should be preserved'); + }); + + await T.test('crud', 'profile', 'GET /settings (round-trip verify)', async function () { + var d = await T.apiGet('/settings'); + T.assert(d.settings.icd_test_key === 'beta', 'GET round-trip: icd_test_key should be beta'); + T.assert(d.settings.icd_test_other === 'keep-me', 'GET round-trip: icd_test_other preserved'); + }); + + // ── Avatar lifecycle: upload → verify in profile → delete → verify gone ── + // Build a minimal 1x1 red PNG as base64 + var tinyPNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + + await T.test('crud', 'profile', 'POST /profile/avatar (upload)', async function () { + var d = await T.apiPost('/profile/avatar', { image: tinyPNG }); + T.assertHasKey(d, 'avatar', 'avatar-upload'); + T.assert(typeof d.avatar === 'string' && d.avatar.indexOf('data:image/png') === 0, 'avatar should be data URI'); + }); + + await T.test('crud', 'profile', 'GET /profile (avatar present)', async function () { + var d = await T.apiGet('/profile'); + T.assert(d.avatar && typeof d.avatar === 'string', 'profile should have avatar after upload'); + }); + + await T.test('crud', 'profile', 'DELETE /profile/avatar', async function () { + await T.safeDelete('/profile/avatar'); + }); + + await T.test('crud', 'profile', 'GET /profile (avatar cleared)', async function () { + var d = await T.apiGet('/profile'); + T.assert(!d.avatar, 'avatar should be null/absent after delete'); + }); + + // ── Clean up test settings keys ── + // Overwrite with empty values — there's no DELETE for individual keys + // so just leave them; they're namespaced with icd_test_ prefix. + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/projects.js b/surfaces/icd-test-runner/js/crud/projects.js new file mode 100644 index 0000000..255cb7b --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/projects.js @@ -0,0 +1,194 @@ +/** + * ICD Test Runner — CRUD: Projects + * Full project lifecycle — CRUD, channel/KB/note associations, files, + * isolation, and admin list. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.projects = async function (testTag) { + + // ── Create ── + var projectId = null; + await T.test('crud', 'projects', 'POST /projects (create, 201 + shape)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/projects', { + name: testTag + '-project', + description: 'ICD integration test project', + color: '#3b82f6', + icon: 'folder' + }); + T.assertStatus(d, 201, 'POST /projects'); + T.assertShape(d, T.S.project, 'project'); + T.assert(d.scope === 'personal', 'scope forced to personal, got ' + d.scope); + T.assert(typeof d.owner_id === 'string' && d.owner_id.length > 0, 'owner_id must be set'); + projectId = d.id; + T.registerCleanup(function () { if (projectId) return T.safeDelete('/projects/' + projectId); }); + }); + + if (!projectId) return; + + // ── Read ── + await T.test('crud', 'projects', 'GET /projects/:id (read, shape)', async function () { + var d = await T.apiGet('/projects/' + projectId); + T.assertShape(d, T.S.project, 'project'); + T.assert(d.id === projectId, 'id mismatch'); + T.assert(d.name === testTag + '-project', 'name mismatch'); + T.assert(d.description === 'ICD integration test project', 'description mismatch'); + }); + + // ── Update ── + await T.test('crud', 'projects', 'PUT /projects/:id (update, returns refreshed)', async function () { + var d = await T.apiPut('/projects/' + projectId, { name: testTag + '-proj-updated', is_archived: false }); + T.assertShape(d, T.S.project, 'project'); + T.assert(d.name === testTag + '-proj-updated', 'name not updated'); + T.assert(d.id === projectId, 'id changed unexpectedly'); + }); + + // ── List envelope ── + await T.test('crud', 'projects', 'GET /projects (list envelope)', async function () { + var d = await T.apiGet('/projects'); + T.assertHasKey(d, 'data', '/projects'); + T.assert(Array.isArray(d.data), 'data must be array'); + var found = d.data.some(function (p) { return p.id === projectId; }); + T.assert(found, 'created project not in list'); + }); + + // ── Channel association ── + var channelId = null; + await T.test('crud', 'projects', 'POST /projects/:id/channels (add channel)', async function () { + // Create a scratch channel for association testing + var ch = await T.apiPost('/channels', { title: testTag + '-proj-ch', type: 'direct' }); + channelId = ch.id; + T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); }); + + var d = await T.apiPost('/projects/' + projectId + '/channels', { channel_id: channelId, position: 0 }); + T.assert(typeof d === 'object', 'expected object response'); + }); + + await T.test('crud', 'projects', 'GET /projects/:id/channels (list, has added)', async function () { + var d = await T.apiGet('/projects/' + projectId + '/channels'); + T.assertHasKey(d, 'data', '/project-channels'); + T.assert(Array.isArray(d.data), 'data must be array'); + T.assert(d.data.length >= 1, 'expected at least 1 channel association'); + var entry = d.data[0]; + T.assert(typeof entry.channel_id === 'string', 'missing channel_id'); + T.assert(typeof entry.project_id === 'string', 'missing project_id'); + T.assert(typeof entry.added_at === 'string', 'missing added_at'); + }); + + await T.test('crud', 'projects', 'DELETE /projects/:id/channels/:channelId (remove)', async function () { + await T.apiDelete('/projects/' + projectId + '/channels/' + channelId); + var d = await T.apiGet('/projects/' + projectId + '/channels'); + T.assert(d.data.length === 0, 'channel should be removed'); + }); + + // ── KB association ── + var kbId = null; + await T.test('crud', 'projects', 'POST /projects/:id/knowledge-bases (add KB)', async function () { + // Create a scratch KB + var kb = await T.apiPost('/knowledge-bases', { name: testTag + '-proj-kb', scope: 'personal' }); + kbId = kb.id; + T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); }); + + var d = await T.apiPost('/projects/' + projectId + '/knowledge-bases', { kb_id: kbId, auto_search: true }); + T.assert(typeof d === 'object', 'expected object response'); + }); + + await T.test('crud', 'projects', 'GET /projects/:id/knowledge-bases (list, has added)', async function () { + var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases'); + T.assertHasKey(d, 'data', '/project-kbs'); + T.assert(d.data.length >= 1, 'expected at least 1 KB association'); + var entry = d.data[0]; + T.assert(typeof entry.kb_id === 'string', 'missing kb_id'); + T.assert(typeof entry.project_id === 'string', 'missing project_id'); + T.assert(typeof entry.added_at === 'string', 'missing added_at'); + }); + + await T.test('crud', 'projects', 'DELETE /projects/:id/knowledge-bases/:kbId (remove)', async function () { + await T.apiDelete('/projects/' + projectId + '/knowledge-bases/' + kbId); + var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases'); + T.assert(d.data.length === 0, 'KB should be removed'); + }); + + // ── Note association ── + var noteId = null; + await T.test('crud', 'projects', 'POST /projects/:id/notes (add note)', async function () { + // Create a scratch note + var n = await T.apiPost('/notes', { title: testTag + '-proj-note', content: 'test' }); + noteId = n.id; + T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); }); + + var d = await T.apiPost('/projects/' + projectId + '/notes', { note_id: noteId }); + T.assert(typeof d === 'object', 'expected object response'); + }); + + await T.test('crud', 'projects', 'GET /projects/:id/notes (list, has added)', async function () { + var d = await T.apiGet('/projects/' + projectId + '/notes'); + T.assertHasKey(d, 'data', '/project-notes'); + T.assert(d.data.length >= 1, 'expected at least 1 note association'); + var entry = d.data[0]; + T.assert(typeof entry.note_id === 'string', 'missing note_id'); + T.assert(typeof entry.project_id === 'string', 'missing project_id'); + T.assert(typeof entry.added_at === 'string', 'missing added_at'); + }); + + await T.test('crud', 'projects', 'DELETE /projects/:id/notes/:noteId (remove)', async function () { + await T.apiDelete('/projects/' + projectId + '/notes/' + noteId); + var d = await T.apiGet('/projects/' + projectId + '/notes'); + T.assert(d.data.length === 0, 'note should be removed'); + }); + + // ── Files (shape only — no upload in runner) ── + await T.test('crud', 'projects', 'GET /projects/:id/files (files key + count)', async function () { + var d = await T.apiGet('/projects/' + projectId + '/files'); + T.assertHasKey(d, 'files', '/project-files'); + T.assertHasKey(d, 'count', '/project-files'); + T.assert(Array.isArray(d.files), 'files must be array'); + T.assert(typeof d.count === 'number', 'count must be number'); + }); + + // ── Isolation ── + await T.test('crud', 'projects', 'isolation: other user cannot GET personal project', async function () { + var other = T.getFixtureUser('-user'); + if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; } + var d = await T.authFetch(other.token, 'GET', '/projects/' + projectId); + T.assert(d._status === 404, 'expected 404 for other user, got ' + d._status); + }); + + await T.test('crud', 'projects', 'isolation: other user cannot DELETE personal project', async function () { + var other = T.getFixtureUser('-user'); + if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; } + var d = await T.authFetch(other.token, 'DELETE', '/projects/' + projectId); + T.assert(d._status === 404, 'expected 404 for other user DELETE, got ' + d._status); + }); + + // ── Admin list ── + await T.test('crud', 'projects', 'GET /admin/projects (envelope + owner_name)', async function () { + var d = await T.apiGet('/admin/projects'); + T.assertHasKey(d, 'data', '/admin/projects'); + T.assert(Array.isArray(d.data), 'data must be array'); + var found = d.data.find(function (p) { return p.id === projectId; }); + T.assert(found, 'created project not in admin list'); + T.assert(typeof found.owner_name === 'string', 'admin list must include owner_name'); + T.assert(typeof found.channel_count === 'number', 'admin list must include channel_count'); + T.assert(typeof found.kb_count === 'number', 'admin list must include kb_count'); + T.assert(typeof found.note_count === 'number', 'admin list must include note_count'); + }); + + // ── Delete ── + await T.test('crud', 'projects', 'DELETE /projects/:id', async function () { + await T.safeDelete('/projects/' + projectId); + projectId = null; + }); + + // Cleanup scratch resources (cleanups run in reverse but let's be explicit) + if (channelId) { try { await T.safeDelete('/channels/' + channelId); channelId = null; } catch (e) { /* ok */ } } + if (kbId) { try { await T.safeDelete('/knowledge-bases/' + kbId); kbId = null; } catch (e) { /* ok */ } } + if (noteId) { try { await T.safeDelete('/notes/' + noteId); noteId = null; } catch (e) { /* ok */ } } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/surfaces.js b/surfaces/icd-test-runner/js/crud/surfaces.js new file mode 100644 index 0000000..ad5530d --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/surfaces.js @@ -0,0 +1,204 @@ +/** + * ICD Test Runner — CRUD: Surfaces + * Surface install lifecycle (install → read → admin list → user list → + * disable → hidden from user → still in admin → enable → visible again → + * core disable rejection → not found → disable not found → missing manifest → + * core conflict → reseed preserves enabled → delete → delete not found). + * Uses T.crud.buildTestZip() from _helpers.js. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + var buildTestZip = T.crud.buildTestZip; + + T.crud.surfaces = async function (testTag) { + + // ── Surfaces CRUD ── + + var surfaceId = null; + + await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (happy path)', async function () { + surfaceId = 'icd-test-' + Date.now(); + var manifest = JSON.stringify({ + id: surfaceId, + title: 'ICD Test Surface', + route: '/s/' + surfaceId + }); + var zipBlob = buildTestZip({ 'manifest.json': manifest }); + await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface'); + + // Verify it exists + var d = await T.apiGet('/admin/surfaces/' + surfaceId); + T.assert(d.id === surfaceId, 'installed surface id mismatch'); + T.assert(d.title === 'ICD Test Surface', 'title mismatch'); + T.registerCleanup(async function () { + if (surfaceId) { + var token = await T.getAuthToken(); + return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId); + } + }); + }); + + if (surfaceId) { + await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () { + var d = await T.apiGet('/admin/surfaces/' + surfaceId); + T.assertShape(d, T.S.surfaceAdmin, 'surface detail'); + T.assert(d.id === surfaceId, 'id mismatch'); + T.assert(d.source === 'extension', 'source should be extension'); + T.assert(d.enabled === true, 'newly installed surface should be enabled'); + }); + + await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () { + var d = await T.apiGet('/admin/surfaces'); + T.assertHasKey(d, 'surfaces', '/admin/surfaces'); + var found = d.surfaces.some(function (s) { return s.id === surfaceId; }); + T.assert(found, 'installed surface should appear in admin list'); + }); + + await T.test('crud', 'surfaces', 'GET /surfaces (user list includes new)', async function () { + var d = await T.apiGet('/surfaces'); + T.assertHasKey(d, 'surfaces', '/surfaces'); + var found = d.surfaces.some(function (s) { return s.id === surfaceId; }); + T.assert(found, 'enabled extension surface should appear in user nav list'); + // Verify nav shape: id + title + route, NOT source/enabled + var entry = d.surfaces.find(function (s) { return s.id === surfaceId; }); + T.assert(entry.route !== undefined, 'nav entry should have route'); + T.assert(entry.source === undefined, 'nav entry should NOT have source'); + T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled'); + }); + + await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/disable', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/' + surfaceId + '/disable'); + T.assertStatus(d, 200, 'disable'); + T.assert(d.enabled === false, 'should report disabled'); + }); + + await T.test('crud', 'surfaces', 'GET /surfaces (disabled hidden from user)', async function () { + var d = await T.apiGet('/surfaces'); + var found = d.surfaces.some(function (s) { return s.id === surfaceId; }); + T.assert(!found, 'disabled surface should NOT appear in user nav list'); + }); + + await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () { + var d = await T.apiGet('/admin/surfaces'); + var entry = d.surfaces.find(function (s) { return s.id === surfaceId; }); + T.assert(entry, 'disabled surface should still appear in admin list'); + T.assert(entry.enabled === false, 'should show as disabled in admin list'); + }); + + await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/enable', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/' + surfaceId + '/enable'); + T.assertStatus(d, 200, 'enable'); + T.assert(d.enabled === true, 'should report enabled'); + }); + + await T.test('crud', 'surfaces', 'GET /surfaces (re-enabled visible)', async function () { + var d = await T.apiGet('/surfaces'); + var found = d.surfaces.some(function (s) { return s.id === surfaceId; }); + T.assert(found, 're-enabled surface should appear in user nav list'); + }); + + await T.test('crud', 'surfaces', 'PUT /admin/surfaces/chat/disable (reject → 400)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/chat/disable'); + T.assert(d._status === 400, 'disabling chat should return 400, got ' + d._status); + }); + + await T.test('crud', 'surfaces', 'PUT /admin/surfaces/admin/disable (reject → 400)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/admin/disable'); + T.assert(d._status === 400, 'disabling admin should return 400, got ' + d._status); + }); + + await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/chat (core reject → 400)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'DELETE', + '/admin/surfaces/chat'); + T.assert(d._status === 400, 'deleting core surface should return 400, got ' + d._status); + }); + + await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (not found → 404)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'GET', + '/admin/surfaces/nonexistent-surface-id'); + T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status); + }); + + await T.test('crud', 'surfaces', 'PUT /admin/surfaces/nonexistent/disable (404)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/nonexistent-surface-id/disable'); + T.assert(d._status === 404, 'disable nonexistent should return 404, got ' + d._status); + }); + + await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (missing manifest → 400)', async function () { + var zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' }); + var d; + try { + d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'bad.surface'); + T.assert(false, 'should have thrown'); + } catch (e) { + T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message); + } + }); + + await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (core conflict → 409)', async function () { + var manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' }); + var zipBlob = buildTestZip({ 'manifest.json': manifest }); + var d; + try { + d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'chat.surface'); + // If no throw, check status manually + T.assert(false, 'overwriting core surface should have thrown'); + } catch (e) { + T.assert(e.message.indexOf('409') !== -1, 'core conflict should 409: ' + e.message); + } + }); + + await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (reseed preserves enabled)', async function () { + // Surface was re-enabled above. Disable it, then re-install. + await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/' + surfaceId + '/disable'); + + // Re-install same ID + var manifest = JSON.stringify({ + id: surfaceId, + title: 'ICD Test Surface v2', + route: '/s/' + surfaceId + }); + var zipBlob = buildTestZip({ 'manifest.json': manifest }); + await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface'); + + // Verify title updated but enabled preserved as false + var d = await T.apiGet('/admin/surfaces/' + surfaceId); + T.assert(d.title === 'ICD Test Surface v2', 'title should be updated after reseed'); + T.assert(d.enabled === false, 'enabled should be preserved as false after reseed'); + + // Re-enable for cleanup + await T.authFetch(await T.getAuthToken(), 'PUT', + '/admin/surfaces/' + surfaceId + '/enable'); + }); + + await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/:id', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId); + T.assertStatus(d, 200, 'delete'); + T.assert(d.deleted === true, 'should report deleted'); + + // Verify gone + var check = await T.authFetch(token, 'GET', '/admin/surfaces/' + surfaceId); + T.assert(check._status === 404, 'deleted surface should 404 on get'); + surfaceId = null; + }); + + await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/nonexistent (404)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'DELETE', + '/admin/surfaces/nonexistent-surface-id'); + T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status); + }); + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/tasks.js b/surfaces/icd-test-runner/js/crud/tasks.js new file mode 100644 index 0000000..d2d5a2d --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/tasks.js @@ -0,0 +1,253 @@ +/** + * ICD Test Runner — CRUD: Tasks + * Personal task lifecycle, validation (missing name, invalid cron, + * workflow type), webhook-triggered tasks, webhook secret auto-gen, + * admin task operations. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.tasks = async function (testTag) { + + // ── Personal Tasks CRUD ── + var taskId = null; + var taskTriggerToken = null; + + await T.test('crud', 'tasks', 'POST /tasks (create prompt task)', async function () { + var d = await T.apiPost('/tasks', { + name: testTag + '-task', + description: 'ICD integration test task', + task_type: 'prompt', + schedule: '@daily', + user_prompt: 'Summarize test data', + model_id: 'test-model', + timezone: 'UTC' + }); + T.assertShape(d, T.S.taskFull, 'created task'); + T.assert(d.task_type === 'prompt', 'task_type should be prompt'); + T.assert(d.scope === 'personal', 'default scope should be personal'); + T.assert(d.is_active === true, 'new task should be active'); + T.assert(d.next_run_at !== null && d.next_run_at !== undefined, 'cron task should have next_run_at'); + T.assert(d.max_tokens > 0, 'budget defaults should be applied'); + taskId = d.id; + T.registerCleanup(function () { if (taskId) return T.safeDelete('/tasks/' + taskId); }); + }); + + if (taskId) { + await T.test('crud', 'tasks', 'GET /tasks/:id (read)', async function () { + var d = await T.apiGet('/tasks/' + taskId); + T.assertShape(d, T.S.taskFull, 'task'); + T.assert(d.id === taskId, 'id mismatch'); + T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch'); + }); + + await T.test('crud', 'tasks', 'PUT /tasks/:id (update)', async function () { + var d = await T.apiPut('/tasks/' + taskId, { + name: testTag + '-task-updated', + user_prompt: 'Updated prompt' + }); + T.assert(d.name === testTag + '-task-updated', 'name not updated'); + }); + + await T.test('crud', 'tasks', 'GET /tasks (list mine)', async function () { + var d = await T.apiGet('/tasks'); + T.assertHasKey(d, 'data', '/tasks'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(d.data.length >= 1, 'should have at least 1 task'); + var found = d.data.some(function (t) { return t.id === taskId; }); + T.assert(found, 'created task should be in list'); + }); + + await T.test('crud', 'tasks', 'GET /tasks/:id/runs (list runs)', async function () { + var d = await T.apiGet('/tasks/' + taskId + '/runs'); + T.assertHasKey(d, 'data', '/runs'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('crud', 'tasks', 'POST /tasks/:id/run (run now)', async function () { + var d = await T.apiPost('/tasks/' + taskId + '/run', {}); + T.assertHasKey(d, 'scheduled', '/run'); + T.assert(d.scheduled === true, 'should be scheduled'); + }); + + await T.test('crud', 'tasks', 'POST /tasks/:id/kill (no active run → 404)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/tasks/' + taskId + '/kill'); + // 200 = killed (if run started), 404 = no active run — both acceptable + T.assert(d._status === 200 || d._status === 404, + 'expected 200 or 404, got ' + d._status); + }); + + await T.test('crud', 'tasks', 'PUT /tasks/:id (update schedule)', async function () { + var d = await T.apiPut('/tasks/' + taskId, { schedule: '@hourly' }); + T.assert(d.schedule === '@hourly', 'schedule not updated'); + }); + + await T.test('crud', 'tasks', 'PUT /tasks/:id (deactivate)', async function () { + var d = await T.apiPut('/tasks/' + taskId, { is_active: false }); + T.assert(d.is_active === false, 'should be deactivated'); + }); + + await T.test('crud', 'tasks', 'DELETE /tasks/:id', async function () { + await T.safeDelete('/tasks/' + taskId); + taskId = null; + }); + } + + // ── Task Validation Tests ── + await T.test('crud', 'tasks', 'POST /tasks (missing name → 400)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/tasks', { + task_type: 'prompt', schedule: '@daily', + user_prompt: 'x', model_id: 'm' + }); + T.assertStatus(d, 400, 'missing name'); + }); + + await T.test('crud', 'tasks', 'POST /tasks (invalid cron → 400)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/tasks', { + name: testTag + '-bad-cron', task_type: 'prompt', + schedule: 'not-a-cron', user_prompt: 'x', model_id: 'm' + }); + T.assertStatus(d, 400, 'invalid cron'); + }); + + await T.test('crud', 'tasks', 'POST /tasks (workflow type → 400 not implemented)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/tasks', { + name: testTag + '-wf', task_type: 'workflow', + schedule: '@daily', workflow_id: '00000000-0000-0000-0000-000000000001', + model_id: 'm' + }); + T.assertStatus(d, 400, 'workflow type rejected'); + }); + + // ── Webhook-Triggered Task + Trigger Endpoint ── + var webhookTaskId = null; + var webhookTriggerToken = null; + + await T.test('crud', 'tasks', 'POST /tasks (create webhook task)', async function () { + var d = await T.apiPost('/tasks', { + name: testTag + '-webhook-task', + task_type: 'prompt', + schedule: 'webhook', + user_prompt: 'Process trigger data', + model_id: 'test-model' + }); + T.assertShape(d, T.S.taskFull, 'webhook task'); + T.assert(d.schedule === 'webhook', 'schedule should be webhook'); + T.assert(d.trigger_token && d.trigger_token.length > 0, 'webhook task should have trigger_token'); + T.assert(d.next_run_at === null || d.next_run_at === undefined, 'webhook task should have no next_run_at'); + webhookTaskId = d.id; + webhookTriggerToken = d.trigger_token; + T.registerCleanup(function () { if (webhookTaskId) return T.safeDelete('/tasks/' + webhookTaskId); }); + }); + + if (webhookTaskId && webhookTriggerToken) { + await T.test('crud', 'tasks', 'POST /hooks/t/:token (fire trigger → 202)', async function () { + var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { + build_id: 12345, status: 'failed', repo: 'icd-test' + }); + T.assertStatus(d, 202, 'trigger'); + T.assert(d.triggered === true, 'should be triggered'); + T.assert(d.run_id && d.run_id.length > 0, 'should return run_id'); + T.assert(d.task_id === webhookTaskId, 'task_id should match'); + }); + + await T.test('crud', 'tasks', 'POST /hooks/t/:token (duplicate → 409)', async function () { + var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true }); + T.assertStatus(d, 409, 'duplicate trigger'); + }); + + await T.test('crud', 'tasks', 'POST /hooks/t/bad-token (invalid → 404)', async function () { + var d = await T.publicPost('/hooks/t/nonexistent-token-value', { test: true }); + T.assertStatus(d, 404, 'bad token'); + }); + + // Deactivate then trigger → 410 + await T.test('crud', 'tasks', 'PUT+POST deactivated webhook task → 410', async function () { + await T.apiPut('/tasks/' + webhookTaskId, { is_active: false }); + var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true }); + T.assertStatus(d, 410, 'inactive trigger'); + // Re-activate for cleanup + await T.apiPut('/tasks/' + webhookTaskId, { is_active: true }); + }); + + await T.test('crud', 'tasks', 'GET /tasks/:id/runs (webhook task has queued run)', async function () { + var d = await T.apiGet('/tasks/' + webhookTaskId + '/runs'); + T.assertHasKey(d, 'data', '/runs'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(d.data.length >= 1, 'webhook task should have at least 1 run from trigger'); + T.assertShape(d.data[0], T.S.taskRun, 'run[0]'); + }); + + await T.test('crud', 'tasks', 'DELETE /tasks/:id (webhook task)', async function () { + await T.safeDelete('/tasks/' + webhookTaskId); + webhookTaskId = null; + }); + } + + // ── Webhook Secret Auto-Generation ── + await T.test('crud', 'tasks', 'POST /tasks (webhook_url → auto webhook_secret)', async function () { + var d = await T.apiPost('/tasks', { + name: testTag + '-secret-task', + task_type: 'prompt', schedule: '@daily', + user_prompt: 'x', model_id: 'm', + webhook_url: 'https://example.com/hook' + }); + T.assert(d.webhook_secret && d.webhook_secret.length > 0, + 'webhook_secret should be auto-generated when webhook_url is set'); + if (d.id) await T.safeDelete('/tasks/' + d.id); + }); + + // ── Admin Task Operations ── + if (T.user.role === 'admin') { + var adminTaskId = null; + + await T.test('crud', 'tasks', 'POST /tasks (create for admin ops)', async function () { + var d = await T.apiPost('/tasks', { + name: testTag + '-admin-task', + task_type: 'prompt', schedule: '@daily', + user_prompt: 'admin test', model_id: 'm' + }); + adminTaskId = d.id; + T.registerCleanup(function () { if (adminTaskId) return T.safeDelete('/tasks/' + adminTaskId); }); + }); + + await T.test('crud', 'tasks', 'GET /admin/tasks (list all)', async function () { + var d = await T.apiGet('/admin/tasks'); + T.assertHasKey(d, 'data', '/admin/tasks'); + T.assert(Array.isArray(d.data), 'data should be array'); + if (adminTaskId) { + var found = d.data.some(function (t) { return t.id === adminTaskId; }); + T.assert(found, 'admin-created task should appear in admin list'); + } + }); + + if (adminTaskId) { + await T.test('crud', 'tasks', 'POST /admin/tasks/:id/run (admin run)', async function () { + var d = await T.apiPost('/admin/tasks/' + adminTaskId + '/run', {}); + T.assertHasKey(d, 'scheduled', '/admin/run'); + T.assert(d.scheduled === true, 'should be scheduled'); + }); + + await T.test('crud', 'tasks', 'POST /admin/tasks/:id/kill (admin kill)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/admin/tasks/' + adminTaskId + '/kill'); + T.assert(d._status === 200 || d._status === 404, + 'expected 200 or 404, got ' + d._status); + }); + + await T.test('crud', 'tasks', 'DELETE /admin/tasks/:id (admin delete)', async function () { + await T.safeDelete('/admin/tasks/' + adminTaskId); + adminTaskId = null; + }); + } + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/teams.js b/surfaces/icd-test-runner/js/crud/teams.js new file mode 100644 index 0000000..713d9fe --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/teams.js @@ -0,0 +1,281 @@ +/** + * ICD Test Runner — CRUD: Teams + * Admin team lifecycle (create → read → update → members → team-scoped + * endpoints → team tasks → groups → grants → permissions → audit → + * usage → assignments → member remove → delete). + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.teams = async function (testTag) { + + // ── Teams CRUD (admin — create team, exercise team-scoped endpoints) ── + if (T.user.role === 'admin') { + var teamId = null; + + await T.test('crud', 'teams', 'POST /admin/teams (create)', async function () { + var d = await T.apiPost('/admin/teams', { name: testTag + '-team', description: 'ICD test team' }); + T.assertShape(d, T.S.team, 'team'); + teamId = d.id; + T.registerCleanup(function () { if (teamId) return T.safeDelete('/admin/teams/' + teamId); }); + }); + + if (teamId) { + await T.test('crud', 'teams', 'GET /admin/teams/:id (read)', async function () { + var d = await T.apiGet('/admin/teams/' + teamId); + T.assertShape(d, T.S.team, 'team'); + T.assert(d.id === teamId, 'id mismatch'); + }); + + await T.test('crud', 'teams', 'PUT /admin/teams/:id (update)', async function () { + var d = await T.apiPut('/admin/teams/' + teamId, { name: testTag + '-team-updated' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // Add current user as team admin + await T.test('crud', 'teams', 'POST /admin/teams/:id/members (add self)', async function () { + var d = await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'teams', 'GET /admin/teams/:id/members', async function () { + var d = await T.apiGet('/admin/teams/' + teamId + '/members'); + T.assertHasKey(d, 'data', '/team-members'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(d.data.length >= 1, 'expected at least 1 member'); + }); + + // Team-scoped endpoints (via team admin routes) + await T.test('crud', 'teams', 'GET /teams/:teamId/members (team route)', async function () { + var d = await T.apiGet('/teams/' + teamId + '/members'); + T.assertHasKey(d, 'data', '/team-members'); + }); + + // Member update + remove (need a second user — use admin self-membership) + await T.test('crud', 'teams', 'PUT /admin/teams/:id/members/:mid (update role)', async function () { + // Get own membership ID + var d = await T.apiGet('/admin/teams/' + teamId + '/members'); + var me = d.data.find(function (m) { return m.user_id === T.user.id; }); + T.assert(me, 'should find self in members'); + // Update role to member then back to admin + var r = await T.apiPut('/admin/teams/' + teamId + '/members/' + me.id, { role: 'member' }); + T.assert(typeof r === 'object', 'expected object'); + // Restore admin role + await T.apiPut('/admin/teams/' + teamId + '/members/' + me.id, { role: 'admin' }); + }); + + // Team groups listing + await T.test('crud', 'teams', 'GET /teams/:teamId/groups', async function () { + var d = await T.apiGet('/teams/' + teamId + '/groups'); + T.assertHasKey(d, 'data', '/team-groups'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/personas', async function () { + var d = await T.apiGet('/teams/' + teamId + '/personas'); + T.assertHasKey(d, 'data', '/team-personas'); + T.assert(Array.isArray(d.data), 'team personas should be array'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/providers', async function () { + var d = await T.apiGet('/teams/' + teamId + '/providers'); + T.assertHasKey(d, 'data', '/team-providers'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assertHasKey(d, 'allow_team_providers', '/team-providers'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/models', async function () { + var d = await T.apiGet('/teams/' + teamId + '/models'); + T.assertHasKey(d, 'models', '/team-models'); + }); + + // ── Team Tasks CRUD ── + var teamTaskId = null; + + await T.test('crud', 'teams', 'POST /teams/:teamId/tasks (create team task)', async function () { + var d = await T.apiPost('/teams/' + teamId + '/tasks', { + name: testTag + '-team-task', + task_type: 'prompt', + schedule: '@daily', + user_prompt: 'Team task test', + model_id: 'test-model' + }); + T.assertShape(d, T.S.taskFull, 'team task'); + T.assert(d.scope === 'team', 'team task scope should be team'); + T.assert(d.team_id === teamId, 'team_id should match'); + teamTaskId = d.id; + T.registerCleanup(function () { if (teamTaskId) return T.safeDelete('/teams/' + teamId + '/tasks/' + teamTaskId); }); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/tasks (list team tasks)', async function () { + var d = await T.apiGet('/teams/' + teamId + '/tasks'); + T.assertHasKey(d, 'data', '/team-tasks'); + T.assert(Array.isArray(d.data), 'data should be array'); + if (teamTaskId) { + T.assert(d.data.length >= 1, 'should have at least 1 team task'); + var found = d.data.some(function (t) { return t.id === teamTaskId; }); + T.assert(found, 'created team task should be in list'); + } + }); + + if (teamTaskId) { + await T.test('crud', 'teams', 'PUT /teams/:teamId/tasks/:id (update team task)', async function () { + var d = await T.apiPut('/teams/' + teamId + '/tasks/' + teamTaskId, { + name: testTag + '-team-task-updated' + }); + T.assert(d.name === testTag + '-team-task-updated', 'name not updated'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/tasks/:id/runs (team task runs)', async function () { + var d = await T.apiGet('/teams/' + teamId + '/tasks/' + teamTaskId + '/runs'); + T.assertHasKey(d, 'data', '/team-task-runs'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('crud', 'teams', 'POST /teams/:teamId/tasks/:id/run (team task run)', async function () { + var d = await T.apiPost('/teams/' + teamId + '/tasks/' + teamTaskId + '/run', {}); + T.assertHasKey(d, 'scheduled', '/team-run'); + T.assert(d.scheduled === true, 'should be scheduled'); + }); + + await T.test('crud', 'teams', 'POST /teams/:teamId/tasks/:id/kill (team task kill)', async function () { + var token = await T.getAuthToken(); + var d = await T.authFetch(token, 'POST', '/teams/' + teamId + '/tasks/' + teamTaskId + '/kill'); + T.assert(d._status === 200 || d._status === 404, + 'expected 200 or 404, got ' + d._status); + }); + + await T.test('crud', 'teams', 'DELETE /teams/:teamId/tasks/:id (delete team task)', async function () { + await T.safeDelete('/teams/' + teamId + '/tasks/' + teamTaskId); + teamTaskId = null; + }); + } + + await T.test('crud', 'teams', 'GET /teams/:teamId/roles', async function () { + var d = await T.apiGet('/teams/' + teamId + '/roles'); + T.assertHasKey(d, 'data', '/team-roles'); + T.assert(typeof d.data === 'object', 'data should be object (role overrides map)'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/audit', async function () { + var d = await T.apiGet('/teams/' + teamId + '/audit'); + T.assertHasKey(d, 'data', '/team-audit'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assertHasKey(d, 'total', '/team-audit pagination'); + T.assertHasKey(d, 'page', '/team-audit pagination'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/audit/actions', async function () { + var d = await T.apiGet('/teams/' + teamId + '/audit/actions'); + T.assertHasKey(d, 'actions', '/team-audit-actions'); + T.assert(Array.isArray(d.actions), 'actions should be array'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/usage', async function () { + var d = await T.apiGet('/teams/' + teamId + '/usage'); + T.assertHasKey(d, 'totals', '/team-usage'); + T.assertHasKey(d, 'results', '/team-usage'); + }); + + await T.test('crud', 'teams', 'GET /teams/:teamId/assignments', async function () { + var d = await T.apiGet('/teams/' + teamId + '/assignments'); + T.assert(typeof d === 'object', 'expected object'); + }); + + // Admin groups CRUD within team context + var groupId = null; + + await T.test('crud', 'teams', 'POST /admin/groups (create team group)', async function () { + var d = await T.apiPost('/admin/groups', { name: testTag + '-group', scope: 'team', team_id: teamId }); + T.assertShape(d, T.S.group, 'group'); + groupId = d.id; + T.registerCleanup(function () { if (groupId) return T.safeDelete('/admin/groups/' + groupId); }); + }); + + if (groupId) { + await T.test('crud', 'teams', 'GET /admin/groups/:id', async function () { + var d = await T.apiGet('/admin/groups/' + groupId); + T.assertShape(d, T.S.group, 'group'); + }); + + await T.test('crud', 'teams', 'PUT /admin/groups/:id (set permissions)', async function () { + var d = await T.apiPut('/admin/groups/' + groupId, { + permissions: ['model.use', 'kb.read', 'channel.create'] + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'teams', 'POST /admin/groups/:id/members (add self)', async function () { + var d = await T.apiPost('/admin/groups/' + groupId + '/members', { user_id: T.user.id }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'teams', 'GET /admin/groups/:id/members', async function () { + var d = await T.apiGet('/admin/groups/' + groupId + '/members'); + T.assertHasKey(d, 'data', '/group-members'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('crud', 'teams', 'DELETE /admin/groups/:id/members/:uid', async function () { + await T.safeDelete('/admin/groups/' + groupId + '/members/' + T.user.id); + }); + + await T.test('crud', 'teams', 'DELETE /admin/groups/:id', async function () { + await T.safeDelete('/admin/groups/' + groupId); + groupId = null; + }); + } + + // Grants + await T.test('crud', 'teams', 'GET /admin/grants/persona/:id (404 = no grant)', async function () { + // No persona to test, just verify the endpoint responds + try { + var d = await T.apiGet('/admin/grants/persona/00000000-0000-0000-0000-000000000000'); + T.assert(typeof d === 'object', 'expected object'); + } catch (e) { + if (e.message && e.message.indexOf('404') !== -1) return; // expected + throw e; + } + }); + + // User permissions + await T.test('crud', 'teams', 'GET /admin/users/:id/permissions', async function () { + var d = await T.apiGet('/admin/users/' + T.user.id + '/permissions'); + T.assertHasKey(d, 'permissions', '/user-permissions'); + T.assert(Array.isArray(d.permissions), 'permissions should be array'); + T.assertHasKey(d, 'user_id', '/user-permissions'); + T.assertHasKey(d, 'groups', '/user-permissions contributing groups'); + T.assert(Array.isArray(d.groups), 'groups should be array'); + }); + + // List all permissions + await T.test('crud', 'teams', 'GET /admin/permissions', async function () { + var d = await T.apiGet('/admin/permissions'); + T.assertHasKey(d, 'permissions', '/admin/permissions'); + T.assert(Array.isArray(d.permissions), 'permissions should be array'); + T.assert(d.permissions.length > 0, 'should have at least 1 permission'); + }); + + // Member remove (remove self then re-add to keep admin for cleanup) + await T.test('crud', 'teams', 'DELETE /admin/teams/:id/members/:mid (remove)', async function () { + var d = await T.apiGet('/admin/teams/' + teamId + '/members'); + var me = d.data.find(function (m) { return m.user_id === T.user.id; }); + T.assert(me, 'should find self in members'); + var r = await T.safeDelete('/admin/teams/' + teamId + '/members/' + me.id); + T.assert(typeof r === 'object' || r === undefined, 'expected object or void'); + // Re-add self as admin for cleanup + await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' }); + }); + + await T.test('crud', 'teams', 'DELETE /admin/teams/:id', async function () { + await T.safeDelete('/admin/teams/' + teamId); + teamId = null; + }); + } + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/workflows.js b/surfaces/icd-test-runner/js/crud/workflows.js new file mode 100644 index 0000000..844a917 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/workflows.js @@ -0,0 +1,403 @@ +/** + * ICD Test Runner — CRUD: Workflows + * Admin workflow lifecycle (create → stages → activate → publish → + * start instance → advance → reject → delete), visitor E2E, + * assignment claim/complete lifecycle. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.workflows = async function (testTag) { + + // ── Workflows CRUD (admin — requires workflow.create permission) ── + if (T.user.role === 'admin') { + var wfId = null; + var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + + await T.test('crud', 'workflows', 'POST /workflows (create)', async function () { + var d = await T.apiPost('/workflows', { + name: testTag + '-workflow', + slug: wfSlug, + description: 'ICD integration test workflow', + entry_mode: 'team_only', + branding: { accent_color: '#2563eb', tagline: 'ICD Test' }, + retention: { mode: 'archive', delete_after_days: 1 } + }); + T.assertShape(d, T.S.workflow, 'workflow'); + T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug); + wfId = d.id; + T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); }); + }); + + if (wfId) { + await T.test('crud', 'workflows', 'GET /workflows/:id (read)', async function () { + var d = await T.apiGet('/workflows/' + wfId); + T.assertShape(d, T.S.workflow, 'workflow'); + T.assert(d.id === wfId, 'id mismatch'); + T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch'); + }); + + await T.test('crud', 'workflows', 'PATCH /workflows/:id (update)', async function () { + var d = await T.apiPatch('/workflows/' + wfId, { + description: 'Updated by ICD test runner', + webhook_url: 'https://example.com/hook' + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // ── Stages ── + var stageIds = []; + + await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #1)', async function () { + var d = await T.apiPost('/workflows/' + wfId + '/stages', { + name: 'Intake', + ordinal: 0, + persona_id: null, + form_template: { fields: ['name', 'email'] }, + history_mode: 'full', + auto_transition: false, + transition_rules: {} + }); + T.assertShape(d, T.S.workflowStage, 'stage'); + T.assert(d.name === 'Intake', 'name mismatch'); + stageIds.push(d.id); + }); + + await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #2)', async function () { + var d = await T.apiPost('/workflows/' + wfId + '/stages', { + name: 'Review', + ordinal: 1, + persona_id: null, + form_template: {}, + history_mode: 'summary', + auto_transition: false, + transition_rules: {} + }); + T.assertShape(d, T.S.workflowStage, 'stage'); + stageIds.push(d.id); + }); + + await T.test('crud', 'workflows', 'GET /workflows/:id/stages (list)', async function () { + var d = await T.apiGet('/workflows/' + wfId + '/stages'); + T.assertHasKey(d, 'data', '/stages'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length); + T.assertShape(d.data[0], T.S.workflowStage, 'stage[0]'); + }); + + await T.test('crud', 'workflows', 'PUT /workflows/:id/stages/:sid (update)', async function () { + var d = await T.apiPut('/workflows/' + wfId + '/stages/' + stageIds[0], { + name: 'Intake (updated)', + ordinal: 0, + persona_id: null, + form_template: { fields: ['name', 'email', 'phone'] }, + history_mode: 'full', + auto_transition: false, + transition_rules: {} + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + if (stageIds.length >= 2) { + await T.test('crud', 'workflows', 'PATCH /workflows/:id/stages/reorder', async function () { + var reversed = stageIds.slice().reverse(); + var d = await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { + ordered_ids: reversed + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // Restore original order for subsequent tests + await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { ordered_ids: stageIds }); + } + + // ── Activate workflow (required before starting instances) ── + await T.test('crud', 'workflows', 'PATCH /workflows/:id (activate)', async function () { + var d = await T.apiPatch('/workflows/' + wfId, { is_active: true }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // ── Publish version ── + var versionNum = null; + + await T.test('crud', 'workflows', 'POST /workflows/:id/publish', async function () { + var d = await T.apiPost('/workflows/' + wfId + '/publish', {}); + T.assertShape(d, T.S.workflowVersion, 'version'); + T.assertHasKey(d, 'snapshot', 'version'); + versionNum = d.version_number; + }); + + if (versionNum !== null) { + await T.test('crud', 'workflows', 'GET /workflows/:id/versions/:v (read)', async function () { + var d = await T.apiGet('/workflows/' + wfId + '/versions/' + versionNum); + T.assertShape(d, T.S.workflowVersion, 'version'); + T.assert(d.version_number === versionNum, 'version mismatch'); + T.assertHasKey(d, 'snapshot', 'version'); + }); + + await T.test('crud', 'workflows', 'POST /workflows/:id/publish (409 duplicate)', async function () { + try { + await T.apiPost('/workflows/' + wfId + '/publish', {}); + throw new Error('expected 409 conflict, got success'); + } catch (e) { + // 409 is the expected result + if (e.message.indexOf('409') === -1 && e.message.indexOf('conflict') === -1 && + e.message.indexOf('already') === -1 && e.message.indexOf('expected 409') !== -1) + throw e; + // Otherwise: got the expected rejection + } + }); + } + + // ── Start instance → status → advance → reject ── + var instanceChannelId = null; + + await T.test('crud', 'workflows', 'POST /workflows/:id/start (instance)', async function () { + var d = await T.apiPost('/workflows/' + wfId + '/start', {}); + // Response may be: direct channel { id }, or { channel_id }, or { channel: { id } }, or { data: { id } } + var cid = d.id || d.channel_id || (d.channel && d.channel.id) || (d.data && d.data.id); + T.assert(cid, 'no channel ID in start response, got keys: ' + Object.keys(d).join(', ')); + instanceChannelId = cid; + T.registerCleanup(function () { if (instanceChannelId) return T.safeDelete('/channels/' + instanceChannelId); }); + }); + + if (instanceChannelId) { + await T.test('crud', 'workflows', 'GET /channels/:id/workflow/status', async function () { + var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); + T.assertShape(d, T.S.workflowStatus, 'status'); + T.assert(d.current_stage === 0, 'expected stage 0, got ' + d.current_stage); + T.assert(d.status === 'active', 'expected active, got ' + d.status); + }); + + await T.test('crud', 'workflows', 'POST /channels/:id/workflow/advance', async function () { + var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/advance', { + data: { name: 'ICD Test', email: 'test@icd.local' } + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'workflows', 'GET workflow/status (after advance)', async function () { + var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); + T.assertShape(d, T.S.workflowStatus, 'status'); + // Should be at stage 1 (or completed if only 2 stages and auto-completed) + T.assert(d.current_stage >= 1 || d.status === 'completed', + 'expected stage >= 1 or completed, got stage=' + d.current_stage + ' status=' + d.status); + }); + + // Reject back to stage 0 (only if still active and not at stage 0) + await T.test('crud', 'workflows', 'POST /channels/:id/workflow/reject', async function () { + var st = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); + if (st.status === 'completed' || st.current_stage === 0) { + // Can't reject from stage 0 or completed — skip gracefully + return; + } + var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/reject', { + reason: 'ICD test: rejecting back to previous stage' + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + // Verify reject went back + await T.test('crud', 'workflows', 'GET workflow/status (after reject)', async function () { + var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); + T.assertShape(d, T.S.workflowStatus, 'status'); + // Should be back at 0 or still active + T.assert(d.status === 'active' || d.status === 'completed', + 'expected active or completed, got ' + d.status); + }); + + // Clean up instance channel before workflow delete (cascade may do it, but be explicit) + await T.test('crud', 'workflows', 'DELETE instance channel', async function () { + await T.safeDelete('/channels/' + instanceChannelId); + instanceChannelId = null; + }); + } + + // ── Stage delete ── + if (stageIds.length >= 2) { + await T.test('crud', 'workflows', 'DELETE /workflows/:id/stages/:sid', async function () { + await T.safeDelete('/workflows/' + wfId + '/stages/' + stageIds[stageIds.length - 1]); + stageIds.pop(); + }); + } + + // ── Workflow delete ── + await T.test('crud', 'workflows', 'DELETE /workflows/:id', async function () { + await T.safeDelete('/workflows/' + wfId); + wfId = null; + }); + } + } + + // ── Visitor Workflow E2E ────────────────────────────────── + // Tests the anonymous session flow: create public_link workflow → visitor + // starts via entry API (no JWT) → sends messages via session cookie. + await (async function () { + var vwfId = null; + var vwfSlug = testTag + '-visitor-wf'; + var visitorChannelId = null; + + await T.test('crud', 'workflows', 'POST visitor workflow (public_link)', async function () { + var d = await T.apiPost('/workflows', { + name: testTag + ' Visitor WF', + slug: vwfSlug, + entry_mode: 'public_link' + }); + T.assertShape(d, T.S.workflow, 'visitor workflow'); + vwfId = d.id; + T.registerCleanup(function () { if (vwfId) return T.safeDelete('/workflows/' + vwfId); }); + }); + + if (vwfId) { + await T.test('crud', 'workflows', 'POST visitor workflow stage', async function () { + await T.apiPost('/workflows/' + vwfId + '/stages', { + name: 'Intake', ordinal: 0, history_mode: 'full' + }); + }); + + await T.test('crud', 'workflows', 'PATCH+publish visitor workflow', async function () { + await T.apiPatch('/workflows/' + vwfId, { is_active: true }); + var pub = await T.apiPost('/workflows/' + vwfId + '/publish', {}); + T.assert(pub._status === undefined || pub._status === 201 || pub.version_number, + 'publish failed'); + }); + + await T.test('crud', 'workflows', 'POST /workflow-entry (visitor start, no auth)', async function () { + var d = await T.publicPost('/workflow-entry/global/' + vwfSlug, {}); + T.assert(d._status === 201, 'expected 201, got ' + d._status); + T.assertHasKey(d, 'channel_id', 'visitor start'); + T.assertHasKey(d, 'session_id', 'visitor start'); + T.assertHasKey(d, 'redirect_to', 'visitor start'); + visitorChannelId = d.channel_id; + T.registerCleanup(function () { + if (visitorChannelId) return T.safeDelete('/channels/' + visitorChannelId); + }); + }); + + if (visitorChannelId) { + await T.test('crud', 'workflows', 'POST /w/:id/messages (visitor session)', async function () { + var d = await T.sessionPost('/w/' + visitorChannelId + '/messages', { + content: 'Hello from visitor test', + role: 'user' + }); + T.assert(d._status === 200 || d._status === 201, 'visitor message: got ' + d._status); + }); + + await T.test('crud', 'workflows', 'GET /w/:id/messages (visitor session)', async function () { + var d = await T.sessionGet('/w/' + visitorChannelId + '/messages'); + T.assert(d._status === 200, 'visitor list messages: got ' + d._status); + var msgs = d.data || d.messages || d; + T.assert(Array.isArray(msgs), 'expected messages array'); + T.assert(msgs.length > 0, 'no messages returned for visitor channel'); + }); + + await T.test('crud', 'workflows', 'GET workflow/status (visitor instance)', async function () { + var d = await T.apiGet('/channels/' + visitorChannelId + '/workflow/status'); + T.assertShape(d, T.S.workflowStatus, 'visitor workflow status'); + T.assert(d.status === 'active', 'visitor workflow should be active'); + }); + } + + await T.test('crud', 'workflows', 'DELETE visitor workflow', async function () { + if (visitorChannelId) { await T.safeDelete('/channels/' + visitorChannelId); visitorChannelId = null; } + if (vwfId) { await T.safeDelete('/workflows/' + vwfId); vwfId = null; } + }); + } + })(); + + // ── Assignment Claim / Complete ──────────────────────────── + // Tests the full assignment lifecycle: advance creates assignment → + // claim → double-claim 409 → complete → double-complete 409. + await (async function () { + var awfId = null; + var aTeamId = null; + var aChannelId = null; + var assignmentId = null; + + await T.test('crud', 'workflows', 'assignment: create team + workflow', async function () { + var team = await T.apiPost('/admin/teams', { + name: testTag + '-assign-team', description: 'Assignment test' + }); + aTeamId = team.id; + T.registerCleanup(function () { if (aTeamId) return T.safeDelete('/admin/teams/' + aTeamId); }); + + try { await T.apiPost('/admin/teams/' + aTeamId + '/members', { user_id: T.user.id, role: 'admin' }); } + catch (e) { /* auto-add */ } + + var wf = await T.apiPost('/workflows', { + name: testTag + '-assign-wf', entry_mode: 'team_only' + }); + awfId = wf.id; + T.registerCleanup(function () { if (awfId) return T.safeDelete('/workflows/' + awfId); }); + + await T.apiPost('/workflows/' + awfId + '/stages', { + name: 'Collect', ordinal: 0, history_mode: 'full' + }); + await T.apiPost('/workflows/' + awfId + '/stages', { + name: 'Review', ordinal: 1, history_mode: 'full', + assignment_team_id: aTeamId + }); + + await T.apiPatch('/workflows/' + awfId, { is_active: true }); + await T.apiPost('/workflows/' + awfId + '/publish', {}); + }); + + if (awfId) { + await T.test('crud', 'workflows', 'assignment: start + advance to review stage', async function () { + var inst = await T.apiPost('/workflows/' + awfId + '/start', {}); + T.assertHasKey(inst, 'channel_id', 'instance start'); + aChannelId = inst.channel_id; + T.registerCleanup(function () { if (aChannelId) return T.safeDelete('/channels/' + aChannelId); }); + + var adv = await T.apiPost('/channels/' + aChannelId + '/workflow/advance', { + data: { intake: 'done' } + }); + T.assert(adv.current_stage === 1, 'should be at stage 1 after advance'); + }); + + await T.test('crud', 'workflows', 'assignment: GET /workflow-assignments/mine', async function () { + var d = await T.apiGet('/workflow-assignments/mine'); + var arr = d.data || []; + T.assert(arr.length > 0, 'expected at least 1 assignment for my teams'); + var ours = arr.find(function (a) { return a.team_id === aTeamId; }); + T.assert(ours, 'no assignment found for test team'); + assignmentId = ours.id; + }); + + if (assignmentId) { + await T.test('crud', 'workflows', 'assignment: POST claim', async function () { + var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/claim', {}); + T.assert(d.claimed === true, 'claim should return claimed=true'); + }); + + await T.test('crud', 'workflows', 'assignment: POST claim (duplicate → 409)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'POST', + '/workflow-assignments/' + assignmentId + '/claim', {}); + T.assert(d._status === 409, 'double claim should return 409, got ' + d._status); + }); + + await T.test('crud', 'workflows', 'assignment: POST complete', async function () { + var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/complete', {}); + T.assert(d.completed === true, 'complete should return completed=true'); + }); + + await T.test('crud', 'workflows', 'assignment: POST complete (duplicate → 409)', async function () { + var d = await T.authFetch(await T.getAuthToken(), 'POST', + '/workflow-assignments/' + assignmentId + '/complete', {}); + T.assert(d._status === 409, 'double complete should return 409, got ' + d._status); + }); + } + + await T.test('crud', 'workflows', 'assignment: cleanup', async function () { + if (aChannelId) { await T.safeDelete('/channels/' + aChannelId); aChannelId = null; } + if (awfId) { await T.safeDelete('/workflows/' + awfId); awfId = null; } + if (aTeamId) { await T.safeDelete('/admin/teams/' + aTeamId); aTeamId = null; } + }); + } + })(); + + }; +})(); diff --git a/surfaces/icd-test-runner/js/crud/workspaces.js b/surfaces/icd-test-runner/js/crud/workspaces.js new file mode 100644 index 0000000..7a82505 --- /dev/null +++ b/surfaces/icd-test-runner/js/crud/workspaces.js @@ -0,0 +1,155 @@ +/** + * ICD Test Runner — CRUD: Workspaces + * Workspace lifecycle — create, read, update, file ops, stats, isolation, delete. + * Git credentials list envelope. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + T.crud.workspaces = async function (testTag) { + + // ── GET /workspaces — list envelope ── + await T.test('crud', 'workspaces', 'GET /workspaces (list envelope)', async function () { + var d = await T.apiGet('/workspaces'); + T.assertHasKey(d, 'data', 'GET /workspaces'); + T.assert(Array.isArray(d.data), 'data must be array'); + }); + + // ── GET /git-credentials — list envelope ── + await T.test('crud', 'workspaces', 'GET /git-credentials (list envelope)', async function () { + var d = await T.apiGet('/git-credentials'); + T.assertHasKey(d, 'data', 'GET /git-credentials'); + T.assert(Array.isArray(d.data), 'data must be array'); + }); + + // ── Workspace CRUD ── + var wsId = null; + await T.test('crud', 'workspaces', 'POST /workspaces (create)', async function () { + var d = await T.apiPost('/workspaces', { + name: testTag + '-ws', + owner_type: 'user', + owner_id: T.user.id + }); + T.assertShape(d, T.S.workspace, 'workspace'); + T.assert(d.owner_id === T.user.id, 'owner_id mismatch'); + T.assert(d.status === 'active', 'status should be active'); + wsId = d.id; + T.registerCleanup(function () { if (wsId) return T.safeDelete('/workspaces/' + wsId); }); + }); + + if (wsId) { + await T.test('crud', 'workspaces', 'GET /workspaces/:id (read)', async function () { + var d = await T.apiGet('/workspaces/' + wsId); + T.assertShape(d, T.S.workspace, 'workspace'); + T.assert(d.id === wsId, 'id mismatch'); + T.assert(d.name === testTag + '-ws', 'name mismatch'); + }); + + // ── PATCH /workspaces/:id ── + await T.test('crud', 'workspaces', 'PATCH /workspaces/:id (update name)', async function () { + var d = await T.apiPatch('/workspaces/' + wsId, { name: testTag + '-ws-updated' }); + T.assertShape(d, T.S.workspace, 'workspace-patched'); + T.assert(d.name === testTag + '-ws-updated', 'name not updated'); + }); + + // ── root_path must never be exposed ── + await T.test('crud', 'workspaces', 'GET /workspaces/:id (no root_path)', async function () { + var d = await T.apiGet('/workspaces/' + wsId); + T.assert(d.root_path === undefined, 'root_path must not be exposed in API'); + }); + + // ── File operations ── + await T.test('crud', 'workspaces', 'GET /workspaces/:id/files (list root)', async function () { + var d = await T.apiGet('/workspaces/' + wsId + '/files?path=/'); + T.assertHasKey(d, 'data', 'ListFiles envelope'); + T.assert(Array.isArray(d.data), 'files should be array'); + }); + + // mkdir + await T.test('crud', 'workspaces', 'POST /workspaces/:id/files/mkdir', async function () { + var d = await T.apiPost('/workspaces/' + wsId + '/files/mkdir?path=/testdir', {}); + T.assert(d.ok === true, 'mkdir should return ok:true'); + T.assert(d.path === '/testdir', 'path mismatch'); + }); + + // Write a file then read it back + await T.test('crud', 'workspaces', 'PUT /workspaces/:id/files/write', async function () { + var d = await T.apiPut('/workspaces/' + wsId + '/files/write?path=' + encodeURIComponent('/test.txt'), { + content: 'Hello from ICD test runner' + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('crud', 'workspaces', 'GET /workspaces/:id/files/read', async function () { + var d = await T.apiGet('/workspaces/' + wsId + '/files/read?path=/test.txt'); + T.assertHasKey(d, 'content', '/ws-file-read'); + T.assert(d.content === 'Hello from ICD test runner', 'content mismatch'); + }); + + await T.test('crud', 'workspaces', 'GET /workspaces/:id/stats', async function () { + var d = await T.apiGet('/workspaces/' + wsId + '/stats'); + T.assert(typeof d === 'object', 'expected object'); + T.assertHasKey(d, 'file_count', 'stats.file_count'); + T.assertHasKey(d, 'total_bytes', 'stats.total_bytes'); + }); + + await T.test('crud', 'workspaces', 'GET /workspaces/:id/index-status', async function () { + var d = await T.apiGet('/workspaces/' + wsId + '/index-status'); + T.assertHasKey(d, 'workspace_id', 'index-status.workspace_id'); + T.assertHasKey(d, 'status_counts', 'index-status.status_counts'); + T.assertHasKey(d, 'total_chunks', 'index-status.total_chunks'); + }); + + // ── File cleanup ── + await T.test('crud', 'workspaces', 'DELETE /workspaces/:id/files (file)', async function () { + try { + await T.safeDelete('/workspaces/' + wsId + '/files/delete?path=' + encodeURIComponent('/test.txt')); + } catch (e) { + if (e.message && e.message.indexOf('404') === -1) throw e; + } + }); + + await T.test('crud', 'workspaces', 'DELETE /workspaces/:id', async function () { + await T.safeDelete('/workspaces/' + wsId); + wsId = null; + }); + } + + // ── Workspace isolation: other user cannot access ── + // Create workspace as running admin user, try to access from fixture regular user + if (T.fixtures.ready) { + var fixtureUser = T.getFixtureUser('-user'); + if (fixtureUser && fixtureUser.token) { + var isolWsId = null; + + await T.test('crud', 'workspaces', 'isolation: create admin workspace', async function () { + var d = await T.apiPost('/workspaces', { + name: testTag + '-iso-ws', + owner_type: 'user', + owner_id: T.user.id + }); + T.assertShape(d, T.S.workspace, 'workspace-iso'); + isolWsId = d.id; + T.registerCleanup(function () { if (isolWsId) return T.safeDelete('/workspaces/' + isolWsId); }); + }); + + if (isolWsId) { + await T.test('crud', 'workspaces', 'isolation: user cannot GET other workspace', async function () { + var d = await T.authFetch(fixtureUser.token, 'GET', '/workspaces/' + isolWsId); + T.assert(d._status === 403, 'expected 403, got ' + d._status); + }); + + // cleanup + await T.test('crud', 'workspaces', 'isolation: cleanup admin workspace', async function () { + await T.safeDelete('/workspaces/' + isolWsId); + isolWsId = null; + }); + } + } + } + + }; +})(); diff --git a/surfaces/icd-test-runner/js/fixtures.js b/surfaces/icd-test-runner/js/fixtures.js new file mode 100644 index 0000000..0f12326 --- /dev/null +++ b/surfaces/icd-test-runner/js/fixtures.js @@ -0,0 +1,102 @@ +/** + * ICD Test Runner — Fixtures + * Creates and tears down test users, teams, and groups for AuthZ tests. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + function genPassword() { + var chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$'; + var pw = ''; + for (var i = 0; i < 16; i++) pw += chars.charAt(Math.floor(Math.random() * chars.length)); + return pw; + } + + T.provisionFixtures = async function () { + var fixtures = T.fixtures; + if (fixtures.ready) { + if (typeof UI !== 'undefined') UI.toast('Fixtures already provisioned — tear down first', 'warning'); + return; + } + if (T.user.role !== 'admin') { + if (typeof UI !== 'undefined') UI.toast('Admin required to provision fixtures', 'error'); + return; + } + + var tag = 'icd-' + Date.now(); + var errors = []; + + var userDefs = [ + { username: tag + '-user', display_name: 'ICD Test User', role: 'user' }, + { username: tag + '-tadmin', display_name: 'ICD Test Team Admin', role: 'user' }, + { username: tag + '-admin2', display_name: 'ICD Test Admin', role: 'admin' } + ]; + + for (var i = 0; i < userDefs.length; i++) { + var def = userDefs[i]; + var pw = genPassword(); + try { + var created = await T.apiPost('/admin/users', { + username: def.username, password: pw, + email: def.username + '@example.com', role: def.role + }); + try { await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); } catch (e) { /* may already be active */ } + var login = await T.publicPost('/auth/login', { login: def.username, password: pw }); + fixtures.users.push({ + username: def.username, password: pw, display_name: def.display_name, + role: def.role, id: created.id, token: login.access_token || '' + }); + } catch (e) { + errors.push('User ' + def.username + ': ' + e.message); + } + } + + try { + var team = await T.apiPost('/admin/teams', { name: tag + '-team', description: 'ICD test team' }); + fixtures.team = { id: team.id, name: team.name }; + try { await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: T.user.id, role: 'admin' }); } catch (e) { /* auto-add */ } + var tadmin = fixtures.users.find(function (u) { return u.username.indexOf('-tadmin') !== -1; }); + if (tadmin) await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: tadmin.id, role: 'admin' }); + var regUser = fixtures.users.find(function (u) { return u.username.indexOf('-user') !== -1 && u.username.indexOf('-tadmin') === -1; }); + if (regUser) await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: regUser.id, role: 'member' }); + } catch (e) { errors.push('Team: ' + e.message); } + + try { + var grp = await T.apiPost('/admin/groups', { name: tag + '-group', scope: 'global' }); + fixtures.group = { id: grp.id, name: grp.name }; + await T.apiPut('/admin/groups/' + grp.id, { permissions: ['model.use', 'kb.read'] }); + var regUser2 = fixtures.users.find(function (u) { return u.username.indexOf('-user') !== -1 && u.username.indexOf('-tadmin') === -1; }); + if (regUser2) await T.apiPost('/admin/groups/' + grp.id + '/members', { user_id: regUser2.id }); + } catch (e) { errors.push('Group: ' + e.message); } + + fixtures.ready = true; + if (typeof T.renderFixtures === 'function') T.renderFixtures(); + + if (errors.length > 0) { + if (typeof UI !== 'undefined') UI.toast('Fixtures created with ' + errors.length + ' errors', 'warning'); + console.warn('Fixture errors:', errors); + } else { + if (typeof UI !== 'undefined') UI.toast('Fixtures provisioned (' + fixtures.users.length + ' users, team, group)', 'success'); + } + }; + + T.teardownFixtures = async function () { + var fixtures = T.fixtures; + if (!fixtures.ready) return; + if (fixtures.group) { try { await T.safeDelete('/admin/groups/' + fixtures.group.id); } catch (e) { /* ok */ } } + if (fixtures.team) { try { await T.safeDelete('/admin/teams/' + fixtures.team.id); } catch (e) { /* ok */ } } + for (var i = fixtures.users.length - 1; i >= 0; i--) { + try { await T.safeDelete('/admin/users/' + fixtures.users[i].id); } catch (e) { /* ok */ } + } + T.fixtures = { ready: false, users: [], team: null, group: null }; + if (typeof T.renderFixtures === 'function') T.renderFixtures(); + if (typeof UI !== 'undefined') UI.toast('Test fixtures torn down', 'info'); + }; + + T.getFixtureUser = function (suffix) { + return T.fixtures.users.find(function (u) { return u.username.indexOf(suffix) !== -1; }); + }; + +})(); diff --git a/surfaces/icd-test-runner/js/framework.js b/surfaces/icd-test-runner/js/framework.js new file mode 100644 index 0000000..d572448 --- /dev/null +++ b/surfaces/icd-test-runner/js/framework.js @@ -0,0 +1,397 @@ +/** + * ICD Test Runner — Framework + * DOM helpers, assertion library, ICD shapes, API wrappers, test harness. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + // ─── DOM Helpers ──────────────────────────────────────────── + + T.esc = function (s) { + var el = document.createElement('span'); + el.textContent = String(s); + return el.innerHTML; + }; + + T.$ = function (tag, attrs, children) { + var el = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { + if (k === 'className') el.className = attrs[k]; + else if (k === 'style' && typeof attrs[k] === 'object') + Object.assign(el.style, attrs[k]); + else if (k.indexOf('on') === 0) + el.addEventListener(k.slice(2).toLowerCase(), attrs[k]); + else el.setAttribute(k, attrs[k]); + }); + if (children) { + if (!Array.isArray(children)) children = [children]; + children.forEach(function (c) { + if (typeof c === 'string') el.appendChild(document.createTextNode(c)); + else if (c) el.appendChild(c); + }); + } + return el; + }; + + // ─── Assertion Library ────────────────────────────────────── + + function assertType(val, type, path) { + var actual = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val; + if (type === 'array') { + if (!Array.isArray(val)) return path + ': expected array, got ' + actual; + } else if (type === 'uuid') { + if (typeof val !== 'string' || val.length < 8) return path + ': expected uuid string'; + } else if (type === 'string?') { + if (val !== null && val !== undefined && typeof val !== 'string') + return path + ': expected string|null, got ' + actual; + } else if (type === 'number?') { + if (val !== null && val !== undefined && typeof val !== 'number') + return path + ': expected number|null, got ' + actual; + } else if (type === 'bool') { + if (typeof val !== 'boolean') return path + ': expected boolean, got ' + actual; + } else if (type === 'object') { + if (typeof val !== 'object' || val === null || Array.isArray(val)) + return path + ': expected object, got ' + actual; + } else if (type === 'any') { + // always passes + } else { + if (typeof val !== type) return path + ': expected ' + type + ', got ' + actual; + } + return null; + } + + function validateShape(obj, schema, prefix) { + var errors = []; + prefix = prefix || '$'; + if (typeof obj !== 'object' || obj === null) { + errors.push(prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj)); + return errors; + } + Object.keys(schema).forEach(function (key) { + var spec = schema[key]; + var val = obj[key]; + var path = prefix + '.' + key; + if (typeof spec === 'string') { + if (spec.charAt(spec.length - 1) === '?') { + if (val === undefined || val === null) return; + spec = spec.slice(0, -1); + } else if (val === undefined) { + errors.push(path + ': missing required field'); + return; + } + var e = assertType(val, spec, path); + if (e) errors.push(e); + } else if (typeof spec === 'object' && spec._type) { + if (spec._optional && (val === undefined || val === null)) return; + if (val === undefined) { errors.push(path + ': missing required field'); return; } + var e2 = assertType(val, spec._type, path); + if (e2) errors.push(e2); + } + }); + return errors; + } + + T.assert = function (condition, msg) { + if (!condition) throw new Error(msg || 'assertion failed'); + }; + + T.assertShape = function (obj, schema, label) { + var errs = validateShape(obj, schema, label || '$'); + if (errs.length) throw new Error(errs.join('; ')); + }; + + T.assertArrayOf = function (arr, schema, label) { + T.assert(Array.isArray(arr), (label || '$') + ': expected array'); + if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]'); + }; + + T.assertHasKey = function (obj, key, label) { + T.assert(obj && obj.hasOwnProperty(key), (label || '$') + ': missing key "' + key + '"'); + }; + + T.assertEnum = function (val, allowed, path) { + if (allowed.indexOf(val) === -1) + throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']'); + }; + + T.assertStatus = function (data, expected, label) { + T.assert(data._status === expected, + (label || '$') + ': expected HTTP ' + expected + ', got ' + data._status + + (data.error ? ' (' + data.error + ')' : '')); + }; + + // ─── ICD Shapes ───────────────────────────────────────────── + + var S = T.S; + + S.channel = { id: 'string', title: 'string', type: 'string', created_at: 'string', updated_at: 'string' }; + S.channelFull = { + id: 'string', user_id: 'string', title: 'string', type: 'string', + ai_mode: 'string?', topic: 'string?', + description: 'string?', model: 'string?', provider_config_id: 'string?', + system_prompt: 'string?', is_archived: 'bool', is_pinned: 'bool', + folder: 'string?', folder_id: 'string?', project_id: 'string?', workspace_id: 'string?', + tags: 'array', created_at: 'string', updated_at: 'string' + }; + S.channelModel = { id: 'string', channel_id: 'string', model_id: 'string', is_default: 'bool', created_at: 'string' }; + S.message = { id: 'string', channel_id: 'string', role: 'string', content: 'string', created_at: 'string' }; + S.persona = { id: 'string', name: 'string', scope: 'string', created_at: 'string', updated_at: 'string' }; + S.note = { id: 'string', title: 'string', created_at: 'string', updated_at: 'string' }; + S.project = { + id: 'string', name: 'string', scope: 'string', owner_id: 'string', + is_archived: 'bool', created_at: 'string', updated_at: 'string', + description: 'string?', color: 'string?', icon: 'string?', + team_id: 'string?', workspace_id: 'string?', settings: 'object?', + channel_count: 'number?', kb_count: 'number?', note_count: 'number?' + }; + S.kb = { + id: 'string', name: 'string', scope: 'string', + embedding_config: 'object', document_count: 'number', + chunk_count: 'number', total_bytes: 'number', status: 'string', + created_at: 'string', updated_at: 'string' + }; + S.folder = { id: 'string', name: 'string', created_at: 'string' }; + S.workspace = { id: 'string', name: 'string', owner_type: 'string', owner_id: 'string', status: 'string', created_at: 'string', updated_at: 'string' }; + S.gitCredSummary = { id: 'string', name: 'string', auth_type: 'string', created_at: 'string' }; + S.notification = { id: 'string', type: 'string', title: 'string', read: 'bool', created_at: 'string' }; + S.memory = { id: 'string', scope: 'string', owner_id: 'string', key: 'string', value: 'string', confidence: 'number', status: 'string', created_at: 'string', updated_at: 'string' }; + S.profile = { id: 'string', username: 'string', email: 'string', role: 'string', settings: 'object', created_at: 'string' }; + S.surface = { id: 'string', title: 'string', source: 'string', enabled: 'bool' }; + S.surfaceNav = { id: 'string', title: 'string', route: 'string' }; + S.surfaceAdmin = { id: 'string', title: 'string', manifest: 'object', enabled: 'bool', source: 'string', installed_at: 'string', updated_at: 'string' }; + S.providerConfig = { id: 'string', name: 'string', provider: 'string', scope: 'string' }; + S.safeConfig = { id: 'string', name: 'string', provider: 'string', is_active: 'bool', has_key: 'bool' }; + S.catalogModel = { model_id: 'string', provider: 'string' }; + S.modelEnabled = { id: 'string', model_id: 'string', display_name: 'string', model_type: 'string', source: 'string', provider_config_id: 'string', provider_name: 'string', provider_type: 'string', capabilities: 'object', scope: 'string', is_persona: 'bool', hidden: 'bool' }; + S.modelPreference = { id: 'string', user_id: 'string', model_id: 'string', hidden: 'bool', sort_order: 'number', created_at: 'string', updated_at: 'string' }; + S.task = { id: 'string', name: 'string', task_type: 'string', schedule: 'string', is_active: 'bool', created_at: 'string' }; + S.taskFull = { id: 'string', owner_id: 'string', name: 'string', task_type: 'string', scope: 'string', schedule: 'string', timezone: 'string', is_active: 'bool', max_tokens: 'number', max_tool_calls: 'number', max_wall_clock: 'number', output_mode: 'string', run_count: 'number', created_at: 'string', updated_at: 'string' }; + S.taskRun = { id: 'string', task_id: 'string', status: 'string', started_at: 'string' }; + S.workflow = { id: 'string', name: 'string', slug: 'string', entry_mode: 'string', is_active: 'bool', created_at: 'string', updated_at: 'string' }; + S.workflowStage = { id: 'string', workflow_id: 'string', ordinal: 'number', name: 'string', history_mode: 'string', created_at: 'string' }; + S.workflowVersion = { id: 'string', workflow_id: 'string', version_number: 'number', created_at: 'string' }; + S.workflowStatus = { workflow_id: 'string', workflow_version: 'number', current_stage: 'number', status: 'string' }; + S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' }; + S.team = { id: 'string', name: 'string', created_at: 'string?' }; + S.teamMember = { user_id: 'string', role: 'string' }; + S.group = { id: 'string', name: 'string' }; + S.extension = { id: 'string', ext_id: 'string', name: 'string', tier: 'string', is_enabled: 'bool', is_system: 'bool', scope: 'string', created_at: 'string', updated_at: 'string' }; + S.auditEntry = { id: 'string', action: 'string', created_at: 'string' }; + S.file = { id: 'string', filename: 'string', content_type: 'string', origin: 'string', created_at: 'string' }; + S.participant = { id: 'string', channel_id: 'string', participant_type: 'string', role: 'string', joined_at: 'string' }; + S.adminUser = { id: 'string', username: 'string', role: 'string', created_at: 'string' }; + S.loginResponse = { access_token: 'string', refresh_token: 'string' }; + + // ─── Test Harness ─────────────────────────────────────────── + + T.registerCleanup = function (fn) { T.cleanup.push(fn); }; + + T.runCleanup = async function () { + for (var i = T.cleanup.length - 1; i >= 0; i--) { + try { await T.cleanup[i](); } catch (e) { /* best effort */ } + } + T.cleanup = []; + }; + + T.test = async function (tier, domain, name, fn) { + var t0 = performance.now(); + var entry = { tier: tier, domain: domain, name: name, status: 'pass', duration: 0, detail: '' }; + try { + await fn(); + } catch (e) { + entry.status = 'fail'; + entry.detail = String(e && e.message ? e.message : e); + } + entry.duration = Math.round(performance.now() - t0); + T.results.push(entry); + if (typeof T.renderProgress === 'function') T.renderProgress(); + return entry; + }; + + // ─── API Wrappers ─────────────────────────────────────────── + // API._get/_post/_put already prepend __BASE__. No base prefix here. + + T.apiGet = async function (path) { return await API._get('/api/v1' + path); }; + T.apiPost = async function (path, body) { return await API._post('/api/v1' + path, body); }; + T.apiPut = async function (path, body) { return await API._put('/api/v1' + path, body); }; + + T.apiPatch = async function (path, body) { + if (typeof API._patch === 'function') return await API._patch('/api/v1' + path, body); + return await API._put('/api/v1' + path, body); + }; + + // ─── Token Capture ────────────────────────────────────────── + + var _capturedToken = ''; + + T.captureAuthToken = async function () { + if (_capturedToken) return _capturedToken; + var origFetch = window.fetch; + window.fetch = function (url, opts) { + if (!_capturedToken && opts && opts.headers) { + var auth = ''; + if (opts.headers instanceof Headers) { + auth = opts.headers.get('Authorization') || ''; + } else { + auth = opts.headers['Authorization'] || opts.headers.authorization || ''; + } + if (typeof auth === 'string' && auth.indexOf('Bearer ') === 0) { + _capturedToken = auth.slice(7); + } + } + return origFetch.apply(this, arguments); + }; + try { await API._get('/api/v1/health'); } catch (e) { /* swallow */ } + window.fetch = origFetch; + return _capturedToken; + }; + + function getAuthTokenFromStorage() { + var env = (T.base || '').replace(/^\//, '') || 'default'; + var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token']; + for (var i = 0; i < keys.length; i++) { + try { + var raw = localStorage.getItem(keys[i]); + if (!raw) continue; + try { + var parsed = JSON.parse(raw); + if (parsed) { + var tok = parsed.access_token || parsed.token || parsed.accessToken || ''; + if (tok) return tok; + } + } catch (e2) { /* not JSON */ } + if (raw.length > 20 && raw.indexOf('{') !== 0) return raw; + } catch (e) { /* skip */ } + } + return ''; + } + + T.getAuthToken = async function () { + return _capturedToken || (await T.captureAuthToken()) || getAuthTokenFromStorage(); + }; + + // ─── DELETE (needs raw fetch fallback) ────────────────────── + + T.apiDelete = async function (path) { + if (typeof API._delete === 'function') return await API._delete('/api/v1' + path); + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }); + if (resp.status === 204 || resp.status === 200) { + var text = await resp.text(); + return text ? JSON.parse(text) : {}; + } + throw new Error('DELETE ' + path + ' → ' + resp.status); + }; + + T.safeDelete = async function (path, retries) { + retries = retries || 2; + for (var attempt = 0; attempt <= retries; attempt++) { + try { + return await T.apiDelete(path); + } catch (e) { + // On 502/503, wait and retry (proxy may be recovering) + if (attempt < retries && e.message && (e.message.indexOf('502') !== -1 || e.message.indexOf('503') !== -1)) { + await new Promise(function (r) { setTimeout(r, 500 * (attempt + 1)); }); + continue; + } + // Last resort: try without auth header (some DELETE endpoints accept this) + try { + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'DELETE', credentials: 'same-origin' + }); + if (resp.ok || resp.status === 204) return {}; + } catch (e2) { /* give up */ } + // Swallow cleanup errors on final attempt — don't let cleanup failures mask test results + if (attempt >= retries) { + console.warn('[ICD] cleanup failed: DELETE ' + path + ' — ' + e.message); + return {}; + } + } + } + return {}; + }; + + // ── File Upload (multipart/form-data) ────────────────────── + + T.apiUpload = async function (path, file, filename) { + var token = await T.getAuthToken(); + var fd = new FormData(); + fd.append('file', file, filename || 'test.txt'); + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + credentials: 'same-origin', + body: fd + }); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + if (!resp.ok) throw new Error('Upload ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : '')); + return data; + }; + + // ─── Auth Fetch (explicit token for test users) ───────────── + + T.authFetch = async function (token, method, path, body) { + var opts = { + method: method, + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }; + if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body); + var resp = await fetch(T.base + '/api/v1' + path, opts); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + return data; + }; + + T.publicPost = async function (path, body) { + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + return data; + }; + + // Session-scoped fetch: uses cookies only (no JWT). For visitor workflow tests. + T.sessionGet = async function (path) { + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'GET', + credentials: 'include' + }); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + return data; + }; + + T.sessionPost = async function (path, body) { + var resp = await fetch(T.base + '/api/v1' + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + return data; + }; + +})(); diff --git a/surfaces/icd-test-runner/js/main.js b/surfaces/icd-test-runner/js/main.js new file mode 100644 index 0000000..6860d58 --- /dev/null +++ b/surfaces/icd-test-runner/js/main.js @@ -0,0 +1,113 @@ +/** + * ICD Test Runner — Entry Point / Module Loader + * + * Creates the shared ICD namespace and loads modules in dependency order. + * Each module is an IIFE that attaches to window.ICD. + * + * Load order: + * 1. framework.js — DOM helpers, assertions, shapes, API wrappers, test harness + * 2. fixtures.js — Test user/team/group provisioning + * 3. tier-smoke.js — Read-only GET tests + * 4. crud/_helpers.js — Shared CRUD utilities (ZIP builder) + * 5. crud/*.js — Per-group CRUD test modules (register on T.crud) + * 6. tier-crud.js — CRUD orchestrator (calls each group) + * 7. tier-authz.js — Permission boundary tests + * 8. tier-security.js — Adversarial red-team tests (auth, cross-tenant, input validation) + * 9. tier-providers.js — Three-tier provider CRUD + live completions + * 10. ui.js — Render functions, export, provider setup panel + * 11. (this file) — Boot + */ +(function () { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + // ─── Shared Namespace ─────────────────────────────────────── + window.ICD = { + mount: mount, + manifest: window.__MANIFEST__ || {}, + user: window.__USER__ || {}, + base: window.__BASE__ || '', + + // State (populated by framework.js) + results: [], + cleanup: [], + running: false, + + // DOM refs (populated by ui.js) + el: {}, + + // Module slots (populated by each module) + S: {}, // shapes + crud: {}, // CRUD group functions + fixtures: { ready: false, users: [], team: null, group: null }, + providerSetup: { provider: 'openai', apiKey: '', endpoint: '', configured: false } + }; + + // ─── Script Loader ────────────────────────────────────────── + var surfaceId = window.ICD.manifest.id || 'icd-test-runner'; + var assetBase = '/surfaces/' + surfaceId + '/js/'; + + // In split-deployment (nginx path routing), assets may be under __BASE__ + if (window.ICD.base) { + assetBase = window.ICD.base + assetBase; + } + + var modules = [ + 'framework.js', + 'fixtures.js', + 'tier-smoke.js', + // CRUD helpers + per-group modules (order among groups doesn't matter) + 'crud/_helpers.js', + 'crud/channels.js', + 'crud/models.js', + 'crud/notes.js', + 'crud/projects.js', + 'crud/knowledge.js', + 'crud/workspaces.js', + 'crud/profile.js', + 'crud/notifications.js', + 'crud/memory.js', + 'crud/admin.js', + 'crud/workflows.js', + 'crud/tasks.js', + 'crud/teams.js', + 'crud/personas.js', + 'crud/extensions.js', + 'crud/surfaces.js', + // Orchestrator (must come after all crud/*.js) + 'tier-crud.js', + 'tier-authz.js', + 'tier-security.js', + 'tier-providers.js', + 'ui.js' + ]; + + var loaded = 0; + + function loadNext() { + if (loaded >= modules.length) { + boot(); + return; + } + var script = document.createElement('script'); + script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0'); + script.onload = function () { loaded++; loadNext(); }; + script.onerror = function () { + console.error('[ICD] Failed to load: ' + modules[loaded]); + loaded++; loadNext(); // continue loading remaining modules + }; + document.body.appendChild(script); + } + + function boot() { + if (typeof window.ICD.renderShell === 'function') { + window.ICD.renderShell(); + } else { + mount.innerHTML = '

ICD Test Runner failed to load modules. Check console.

'; + } + } + + loadNext(); +})(); diff --git a/surfaces/icd-test-runner/js/tier-authz.js b/surfaces/icd-test-runner/js/tier-authz.js new file mode 100644 index 0000000..3698230 --- /dev/null +++ b/surfaces/icd-test-runner/js/tier-authz.js @@ -0,0 +1,228 @@ +/** + * ICD Test Runner — AuthZ Tier + * Permission boundary tests: cross-user isolation, role enforcement. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + T.runAuthz = async function () { + if (!T.fixtures.ready) { + if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning'); + return; + } + + var testUser = T.getFixtureUser('-user'); + var teamAdmin = T.getFixtureUser('-tadmin'); + var testAdmin = T.getFixtureUser('-admin2'); + + if (!testUser || !testUser.token) { + T.results.push({ tier: 'authz', domain: 'setup', name: 'SKIP', status: 'fail', duration: 0, + detail: 'Test user not provisioned or login failed (no token)' }); + return; + } + + // ── Regular user MUST be denied admin routes ── + + var adminGets = [ + '/admin/stats', '/admin/users', '/admin/settings', + '/admin/configs', '/admin/models', '/admin/personas', + '/admin/teams', '/admin/groups', '/admin/surfaces', + '/admin/providers/health', '/admin/routing/policies', + '/admin/permissions', '/admin/roles', '/admin/vault/status', + '/admin/storage/status', '/admin/audit', '/admin/pricing', + '/admin/tasks', '/admin/projects', '/admin/channels/archived', + '/admin/extensions' + ]; + + for (var ai = 0; ai < adminGets.length; ai++) { + await (async function (path) { + await T.test('authz', 'admin-deny', 'user → GET ' + path + ' (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'GET', path); + T.assert(d._status === 403 || d._status === 401, + 'expected 403/401 for non-admin, got ' + d._status); + }); + })(adminGets[ai]); + } + + // ── Regular user MUST NOT be able to create admin resources ── + + await T.test('authz', 'admin-deny', 'user → POST /admin/users (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/users', { + username: 'should-not-exist', password: 'x', email: 'no@no.no', role: 'user' + }); + T.assert(d._status === 403 || d._status === 401, + 'expected 403/401, got ' + d._status); + }); + + await T.test('authz', 'admin-deny', 'user → POST /admin/teams (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/teams', { + name: 'should-not-exist' + }); + T.assert(d._status === 403 || d._status === 401, + 'expected 403/401, got ' + d._status); + }); + + await T.test('authz', 'admin-deny', 'user → POST /admin/groups (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/groups', { + name: 'should-not-exist', scope: 'global' + }); + T.assert(d._status === 403 || d._status === 401, + 'expected 403/401, got ' + d._status); + }); + + await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {}); + T.assert(d._status === 403 || d._status === 401 || d._status === 400, + 'expected 403/401/400, got ' + d._status); + }); + + await T.test('authz', 'admin-deny', 'user → PUT /admin/users/:id/role (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'PUT', '/admin/users/' + testUser.id + '/role', { role: 'admin' }); + T.assert(d._status === 403 || d._status === 401, + 'CRITICAL: non-admin was able to escalate role! got ' + d._status); + }); + + await T.test('authz', 'admin-deny', 'user → POST /admin/users/:id/reset-password (expect 403)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/users/' + testUser.id + '/reset-password', { new_password: 'hacked' }); + T.assert(d._status === 403 || d._status === 401, + 'CRITICAL: non-admin was able to reset passwords! got ' + d._status); + }); + + // ── Regular user SHOULD be able to access their own stuff ── + + await T.test('authz', 'user-allow', 'user → GET /profile (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/profile'); + T.assertStatus(d, 200, 'profile'); + T.assert(d.username === testUser.username, 'username mismatch'); + }); + + await T.test('authz', 'user-allow', 'user → GET /channels (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/channels'); + T.assertStatus(d, 200, 'channels'); + }); + + await T.test('authz', 'user-allow', 'user → GET /models/enabled (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/models/enabled'); + T.assertStatus(d, 200, 'models'); + }); + + await T.test('authz', 'user-allow', 'user → GET /surfaces (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/surfaces'); + T.assertStatus(d, 200, 'surfaces'); + }); + + await T.test('authz', 'user-allow', 'user → GET /notifications/unread-count (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/notifications/unread-count'); + T.assertStatus(d, 200, 'unread-count'); + }); + + await T.test('authz', 'user-allow', 'user → GET /teams/mine (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/teams/mine'); + T.assertStatus(d, 200, 'teams/mine'); + }); + + await T.test('authz', 'user-allow', 'user → GET /groups/mine (expect 200)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/groups/mine'); + T.assertStatus(d, 200, 'groups/mine'); + }); + + // ── Permission-gated operations ── + + await T.test('authz', 'perm-gate', 'user → POST /workflows (expect 403, no workflow.create)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/workflows', { + name: 'should-not-exist', slug: 'should-not-exist', entry_mode: 'team_only' + }); + T.assert(d._status === 403 || d._status === 401, + 'expected 403 (no workflow.create), got ' + d._status); + }); + + // ── Team admin can manage their team but NOT platform admin ── + + if (teamAdmin && teamAdmin.token && T.fixtures.team) { + await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/members (expect 200)', async function () { + var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/members'); + T.assertStatus(d, 200, 'team-members'); + }); + + await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/personas (expect 200)', async function () { + var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/personas'); + T.assertStatus(d, 200, 'team-personas'); + }); + + await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/providers (expect 200)', async function () { + var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/providers'); + T.assertStatus(d, 200, 'team-providers'); + }); + + await T.test('authz', 'team-scope', 'teamAdmin → GET /admin/stats (expect 403)', async function () { + var d = await T.authFetch(teamAdmin.token, 'GET', '/admin/stats'); + T.assert(d._status === 403 || d._status === 401, + 'team admin should not have platform admin, got ' + d._status); + }); + + await T.test('authz', 'team-scope', 'teamAdmin → PUT /admin/users/:id/role (expect 403)', async function () { + var d = await T.authFetch(teamAdmin.token, 'PUT', '/admin/users/' + teamAdmin.id + '/role', { role: 'admin' }); + T.assert(d._status === 403 || d._status === 401, + 'CRITICAL: team admin was able to escalate to platform admin! got ' + d._status); + }); + } + + // ── Second admin CAN access admin routes ── + + if (testAdmin && testAdmin.token) { + await T.test('authz', 'admin-allow', 'admin2 → GET /admin/stats (expect 200)', async function () { + var d = await T.authFetch(testAdmin.token, 'GET', '/admin/stats'); + T.assertStatus(d, 200, 'admin-stats'); + }); + + await T.test('authz', 'admin-allow', 'admin2 → GET /admin/users (expect 200)', async function () { + var d = await T.authFetch(testAdmin.token, 'GET', '/admin/users'); + T.assertStatus(d, 200, 'admin-users'); + }); + } + + // ── Expired/invalid token ── + + await T.test('authz', 'token', 'bogus token → GET /profile (expect 401)', async function () { + var d = await T.authFetch('this-is-not-a-valid-token', 'GET', '/profile'); + T.assert(d._status === 401, 'expected 401 for bogus token, got ' + d._status); + }); + + await T.test('authz', 'token', 'empty token → GET /profile (expect 401)', async function () { + var d = await T.authFetch('', 'GET', '/profile'); + T.assert(d._status === 401, 'expected 401 for empty token, got ' + d._status); + }); + + // ── Cross-user resource isolation ── + + var userChannel = null; + await T.test('authz', 'isolation', 'admin creates private channel', async function () { + var d = await T.apiPost('/channels', { title: 'authz-private-' + Date.now(), type: 'direct' }); + T.assertHasKey(d, 'id', 'channel'); + userChannel = d.id; + T.registerCleanup(function () { if (userChannel) return T.safeDelete('/channels/' + userChannel); }); + }); + + if (userChannel && testUser.token) { + await T.test('authz', 'isolation', 'user → GET /channels/:id (expect 403/404)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/channels/' + userChannel); + T.assert(d._status === 403 || d._status === 404, + 'user should not see another user\'s channel, got ' + d._status); + }); + + await T.test('authz', 'isolation', 'user → DELETE /channels/:id (expect 403/404)', async function () { + var d = await T.authFetch(testUser.token, 'DELETE', '/channels/' + userChannel); + T.assert(d._status === 403 || d._status === 404, + 'user should not delete another user\'s channel, got ' + d._status); + }); + + await T.test('authz', 'isolation', 'user → GET /channels/:id/path (expect 403/404)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/channels/' + userChannel + '/path'); + T.assert(d._status === 403 || d._status === 404, + 'user should not read another user\'s messages, got ' + d._status); + }); + } + }; +})(); diff --git a/surfaces/icd-test-runner/js/tier-crud.js b/surfaces/icd-test-runner/js/tier-crud.js new file mode 100644 index 0000000..e8581b8 --- /dev/null +++ b/surfaces/icd-test-runner/js/tier-crud.js @@ -0,0 +1,34 @@ +/** + * ICD Test Runner — CRUD Tier (Orchestrator) + * + * Thin dispatcher that generates a shared testTag and calls each + * CRUD group module in sequence. Each group is registered on T.crud + * by its own IIFE (loaded via crud/*.js). + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + T.runCrud = async function () { + var testTag = 'icd-test-' + Date.now(); + var C = T.crud || {}; + + if (C.channels) await C.channels(testTag); + if (C.models) await C.models(testTag); + if (C.profile) await C.profile(testTag); + if (C.notes) await C.notes(testTag); + if (C.projects) await C.projects(testTag); + if (C.knowledge) await C.knowledge(testTag); + if (C.workspaces) await C.workspaces(testTag); + if (C.notifications) await C.notifications(testTag); + if (C.memory) await C.memory(testTag); + if (C.admin) await C.admin(testTag); + if (C.workflows) await C.workflows(testTag); + if (C.tasks) await C.tasks(testTag); + if (C.teams) await C.teams(testTag); + if (C.personas) await C.personas(testTag); + if (C.extensions) await C.extensions(testTag); + if (C.surfaces) await C.surfaces(testTag); + }; +})(); diff --git a/surfaces/icd-test-runner/js/tier-providers.js b/surfaces/icd-test-runner/js/tier-providers.js new file mode 100644 index 0000000..ddedbb4 --- /dev/null +++ b/surfaces/icd-test-runner/js/tier-providers.js @@ -0,0 +1,471 @@ +/** + * ICD Test Runner — Provider Tier + * Three-tier provider CRUD (global → team → BYOK) with live SSE completions. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + var PROVIDER_DEFAULTS = { + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com', + venice: 'https://api.venice.ai/api/v1', + openrouter: 'https://openrouter.ai/api/v1' + }; + T.PROVIDER_DEFAULTS = PROVIDER_DEFAULTS; + + // ─── SSE Completion Helper ────────────────────────────────── + + T.testCompletion = async function (channelId, model, providerConfigId, label) { + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1/chat/completions', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + channel_id: channelId, + content: 'Reply with exactly: ICD_TEST_OK', + model: model, + provider_config_id: providerConfigId, + tools_enabled: false + }) + }); + + T.assert(resp.ok, label + ': completion returned HTTP ' + resp.status); + T.assert(resp.headers.get('content-type').indexOf('text/event-stream') !== -1, + label + ': expected SSE, got ' + resp.headers.get('content-type')); + + var reader = resp.body.getReader(); + var decoder = new TextDecoder(); + var content = ''; + var gotFinish = false; + var finishData = null; + var buf = ''; + + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buf += decoder.decode(chunk.value, { stream: true }); + var lines = buf.split('\n'); + buf = lines.pop(); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + if (line === 'data: [DONE]') { gotFinish = true; continue; } + if (line.indexOf('event:') === 0) continue; + if (line.indexOf('data:') === 0) { + var raw = line.slice(5).trim(); + try { + var evt = JSON.parse(raw); + // Handler emits OpenAI-format: choices[0].delta.content + if (evt.choices && evt.choices[0] && evt.choices[0].delta && evt.choices[0].delta.content) { + content += evt.choices[0].delta.content; + } else if (evt.content) { + content += evt.content; // fallback for non-OpenAI format + } + if (evt.message_id) { gotFinish = true; finishData = evt; } + } catch (e) { /* skip */ } + } + } + } + + T.assert(content.length > 0, label + ': no content in stream'); + T.assert(gotFinish, label + ': no finish event'); + return { content: content, finish: finishData }; + }; + + // ─── Model Selection ──────────────────────────────────────── + + T.pickCheapestChat = function (models) { + var skipPatterns = ['embed', 'image', 'dall-e', 'tts', 'whisper', 'moderation']; + var chatModels = models.filter(function (m) { + var t = m.model_type || m.type || ''; + if (t === 'embedding' || t === 'image') return false; + // When type is missing, exclude by model ID pattern + var mid = (m.model_id || m.id || '').toLowerCase(); + for (var i = 0; i < skipPatterns.length; i++) { + if (mid.indexOf(skipPatterns[i]) !== -1) return false; + } + return (t === 'chat' || t === 'text' || t === ''); + }); + if (chatModels.length === 0) return models[0] || null; + chatModels.sort(function (a, b) { + return (a.output_price_per_m || a.output_price || 999) - (b.output_price_per_m || b.output_price || 999); + }); + var cheapNames = ['mini', 'haiku', 'flash', 'fast', 'nano', 'lite', 'small']; + for (var i = 0; i < chatModels.length; i++) { + var mid = (chatModels[i].model_id || chatModels[i].id || '').toLowerCase(); + for (var j = 0; j < cheapNames.length; j++) { + if (mid.indexOf(cheapNames[j]) !== -1) return chatModels[i]; + } + } + return chatModels[0]; + }; + + T.runProviders = async function () { + if (!T.providerSetup.configured || !T.providerSetup.apiKey) { + await T.test('provider', 'setup', 'SKIP — no provider configured', async function () { + throw new Error('Configure a provider (type + API key) in the panel above, then run again'); + }); + return; + } + + var tag = 'icd-prov-' + Date.now(); + var prov = T.providerSetup.provider; + var key = T.providerSetup.apiKey; + var endpoint = T.providerSetup.endpoint || PROVIDER_DEFAULTS[prov] || ''; + + // Track IDs for scoping assertions + var globalConfigId = null; + var globalModel = null; + var teamId = null; + var teamConfigId = null; + var teamModel = null; + var byokConfigId = null; + var byokModel = null; + + // ════════════════════════════════════════════════════════════ + // Tier 1: Global (admin) + // ════════════════════════════════════════════════════════════ + + if (T.user.role === 'admin') { + await T.test('provider', 'global', 'POST /admin/configs (create)', async function () { + var d = await T.apiPost('/admin/configs', { + name: tag + '-global', + provider: prov, + endpoint: endpoint, + api_key: key, + is_private: false, + config: {}, + headers: {}, + settings: {} + }); + T.assertHasKey(d, 'id', 'global-config'); + globalConfigId = d.id; + T.registerCleanup(function () { if (globalConfigId) return T.safeDelete('/admin/configs/' + globalConfigId); }); + }); + + if (globalConfigId) { + await T.test('provider', 'global', 'GET /admin/configs/:id (read)', async function () { + var d = await T.apiGet('/admin/configs'); + var configs = d.configs || d.data || d; + T.assert(Array.isArray(configs), 'expected configs array'); + var found = configs.find(function (c) { return c.id === globalConfigId; }); + T.assert(found, 'created config not in list'); + T.assert(found.has_key === true, 'has_key should be true'); + // Key must be redacted + T.assert(!found.api_key, 'api_key should be redacted'); + }); + + await T.test('provider', 'global', 'PUT /admin/configs/:id (update name)', async function () { + var d = await T.apiPut('/admin/configs/' + globalConfigId, { name: tag + '-global-renamed' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('provider', 'global', 'POST /admin/models/fetch (sync catalog)', async function () { + var d = await T.apiPost('/admin/models/fetch', { provider_config_id: globalConfigId }); + T.assert(typeof d === 'object', 'expected object'); + T.assert(d.total !== undefined || d.added !== undefined || d.message, + 'expected sync result, got keys: ' + Object.keys(d).join(', ')); + }); + + // Newly synced models may default to visibility=disabled — enable them + await T.test('provider', 'global', 'PUT /admin/models/bulk (enable visibility)', async function () { + var d = await T.apiPut('/admin/models/bulk', { + provider_config_id: globalConfigId, + visibility: 'enabled' + }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('provider', 'global', 'GET /models/enabled (global visible)', async function () { + var d = await T.apiGet('/models/enabled'); + var models = d.models || d.data || d; + T.assert(Array.isArray(models), 'expected models array'); + var ours = models.filter(function (m) { return m.provider_config_id === globalConfigId; }); + if (ours.length === 0) { + // Fall back to admin catalog — models may be visible there even if not in user endpoint + var catalog = await T.apiGet('/admin/models'); + var allModels = catalog.models || catalog.data || catalog; + if (Array.isArray(allModels)) { + ours = allModels.filter(function (m) { return m.provider_config_id === globalConfigId; }); + } + } + T.assert(ours.length > 0, 'no models visible from global config ' + globalConfigId); + globalModel = T.pickCheapestChat(ours); + T.assert(globalModel, 'no chat model found in global config'); + }); + + if (globalModel) { + var globalChannelId = null; + + await T.test('provider', 'global', 'completion via global provider', async function () { + // Create channel, run completion, verify + var ch = await T.apiPost('/channels', { + title: tag + '-global-test', + type: 'direct', + model: (globalModel.model_id || globalModel.id), + provider_config_id: globalConfigId + }); + globalChannelId = ch.id; + T.registerCleanup(function () { if (globalChannelId) return T.safeDelete('/channels/' + globalChannelId); }); + + var result = await T.testCompletion(globalChannelId, (globalModel.model_id || globalModel.id), globalConfigId, 'global'); + T.assert(result.content.length > 0, 'completion produced no content'); + }); + + if (globalChannelId) { + await T.test('provider', 'global', 'GET /channels/:id/path (message persisted)', async function () { + var d = await T.apiGet('/channels/' + globalChannelId + '/path'); + var msgs = d.messages || d.data || d; + T.assert(Array.isArray(msgs), 'expected messages array'); + // Should have user + assistant messages + var roles = msgs.map(function (m) { return m.role; }); + T.assert(roles.indexOf('user') !== -1, 'missing user message'); + T.assert(roles.indexOf('assistant') !== -1, 'missing assistant message'); + }); + + await T.test('provider', 'global', 'DELETE test channel', async function () { + await T.safeDelete('/channels/' + globalChannelId); + globalChannelId = null; + }); + } + } + } + } + + // ════════════════════════════════════════════════════════════ + // Tier 2: Team provider + // ════════════════════════════════════════════════════════════ + + if (T.user.role === 'admin') { + await T.test('provider', 'team', 'POST /admin/teams (create test team)', async function () { + var d = await T.apiPost('/admin/teams', { name: tag + '-team', description: 'Provider test team' }); + teamId = d.id; + T.registerCleanup(function () { if (teamId) return T.safeDelete('/admin/teams/' + teamId); }); + // Add self as team admin + try { await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' }); } catch (e) { /* auto-added */ } + }); + + if (teamId) { + await T.test('provider', 'team', 'POST /teams/:id/providers (create)', async function () { + var d = await T.apiPost('/teams/' + teamId + '/providers', { + name: tag + '-team-prov', + provider: prov, + endpoint: endpoint, + api_key: key, + config: {}, + headers: {}, + settings: {} + }); + T.assertHasKey(d, 'id', 'team-config'); + teamConfigId = d.id; + T.registerCleanup(function () { if (teamConfigId) return T.safeDelete('/teams/' + teamId + '/providers/' + teamConfigId); }); + }); + + if (teamConfigId) { + await T.test('provider', 'team', 'GET /teams/:id/providers (list)', async function () { + var d = await T.apiGet('/teams/' + teamId + '/providers'); + var configs = d.configs || d.data || d.providers || (Array.isArray(d) ? d : null); + T.assert(Array.isArray(configs), 'expected configs array, got keys: ' + Object.keys(d).join(', ')); + var found = configs.find(function (c) { return c.id === teamConfigId; }); + T.assert(found, 'team config not in list'); + }); + + await T.test('provider', 'team', 'PUT /teams/:id/providers/:id (update)', async function () { + var d = await T.apiPut('/teams/' + teamId + '/providers/' + teamConfigId, { name: tag + '-team-renamed' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('provider', 'team', 'GET /teams/:id/providers/:id/models (fetch)', async function () { + var d = await T.apiGet('/teams/' + teamId + '/providers/' + teamConfigId + '/models'); + var models = d.models || d.data || d; + T.assert(Array.isArray(models), 'expected models array'); + if (models.length > 0) { + teamModel = T.pickCheapestChat(models); + } + }); + + // If no models from team-specific fetch, try /models/enabled filtered + if (!teamModel) { + await T.test('provider', 'team', 'GET /models/enabled (team model fallback)', async function () { + var d = await T.apiGet('/models/enabled'); + var models = d.models || d.data || d; + var ours = models.filter(function (m) { return m.provider_config_id === teamConfigId; }); + if (ours.length > 0) teamModel = T.pickCheapestChat(ours); + T.assert(teamModel, 'no chat model found for team config'); + }); + } + + if (teamModel) { + var teamChannelId = null; + + await T.test('provider', 'team', 'completion via team provider', async function () { + var ch = await T.apiPost('/channels', { + title: tag + '-team-test', + type: 'direct', + model: (teamModel.model_id || teamModel.id), + provider_config_id: teamConfigId + }); + teamChannelId = ch.id; + T.registerCleanup(function () { if (teamChannelId) return T.safeDelete('/channels/' + teamChannelId); }); + + var result = await T.testCompletion(teamChannelId, (teamModel.model_id || teamModel.id), teamConfigId, 'team'); + T.assert(result.content.length > 0, 'completion produced no content'); + }); + + if (teamChannelId) { + await T.test('provider', 'team', 'DELETE test channel', async function () { + await T.safeDelete('/channels/' + teamChannelId); + teamChannelId = null; + }); + } + } + + await T.test('provider', 'team', 'DELETE /teams/:id/providers/:id', async function () { + await T.safeDelete('/teams/' + teamId + '/providers/' + teamConfigId); + teamConfigId = null; + }); + } + + await T.test('provider', 'team', 'DELETE /admin/teams/:id (cleanup)', async function () { + await T.safeDelete('/admin/teams/' + teamId); + teamId = null; + }); + } + } + + // ════════════════════════════════════════════════════════════ + // Tier 3: Personal BYOK + // ════════════════════════════════════════════════════════════ + + // BYOK requires allow_user_byok policy — enable it if admin, skip if not + var byokPolicyWas = null; // null = don't restore + + if (T.user.role === 'admin') { + await T.test('provider', 'byok', 'enable allow_user_byok policy', async function () { + // Read current policy value + try { + var pub = await T.apiGet('/settings/public'); + var policies = pub.policies || {}; + byokPolicyWas = policies.allow_user_byok; + } catch (e) { /* can't read — will try to set anyway */ } + + // Enable BYOK + await T.apiPut('/admin/settings/allow_user_byok', { value: 'true' }); + }); + } + + await T.test('provider', 'byok', 'POST /api-configs (create)', async function () { + var d = await T.apiPost('/api-configs', { + name: tag + '-byok', + provider: prov, + endpoint: endpoint, + api_key: key, + config: {}, + headers: {}, + settings: {} + }); + T.assertHasKey(d, 'id', 'byok-config'); + byokConfigId = d.id; + T.registerCleanup(function () { if (byokConfigId) return T.safeDelete('/api-configs/' + byokConfigId); }); + }); + + if (byokConfigId) { + await T.test('provider', 'byok', 'GET /api-configs/:id (read — key redacted)', async function () { + var d = await T.apiGet('/api-configs/' + byokConfigId); + T.assertHasKey(d, 'id', 'byok-config'); + // has_key may not be present on single-config GET (only on list) — check if present + if (d.has_key !== undefined) T.assert(d.has_key === true, 'has_key should be true'); + T.assert(!d.api_key, 'api_key should be redacted in response'); + }); + + await T.test('provider', 'byok', 'PUT /api-configs/:id (update name)', async function () { + var d = await T.apiPut('/api-configs/' + byokConfigId, { name: tag + '-byok-renamed' }); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('provider', 'byok', 'POST /api-configs/:id/models/fetch (sync)', async function () { + var d = await T.apiPost('/api-configs/' + byokConfigId + '/models/fetch', {}); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('provider', 'byok', 'GET /api-configs/:id/models (list)', async function () { + var d = await T.apiGet('/api-configs/' + byokConfigId + '/models'); + var models = d.models || d.data || d; + T.assert(Array.isArray(models), 'expected models array'); + T.assert(models.length > 0, 'no models returned after fetch'); + byokModel = T.pickCheapestChat(models); + T.assert(byokModel, 'no chat model found in BYOK config'); + }); + + if (byokModel) { + var byokChannelId = null; + + await T.test('provider', 'byok', 'completion via BYOK provider', async function () { + var ch = await T.apiPost('/channels', { + title: tag + '-byok-test', + type: 'direct', + model: (byokModel.model_id || byokModel.id), + provider_config_id: byokConfigId + }); + byokChannelId = ch.id; + T.registerCleanup(function () { if (byokChannelId) return T.safeDelete('/channels/' + byokChannelId); }); + + var result = await T.testCompletion(byokChannelId, (byokModel.model_id || byokModel.id), byokConfigId, 'byok'); + T.assert(result.content.length > 0, 'completion produced no content'); + }); + + if (byokChannelId) { + await T.test('provider', 'byok', 'GET /channels/:id/path (message persisted)', async function () { + var d = await T.apiGet('/channels/' + byokChannelId + '/path'); + var msgs = d.messages || d.data || d; + T.assert(Array.isArray(msgs), 'expected messages array'); + var roles = msgs.map(function (m) { return m.role; }); + T.assert(roles.indexOf('assistant') !== -1, 'missing assistant response'); + }); + + await T.test('provider', 'byok', 'DELETE test channel', async function () { + await T.safeDelete('/channels/' + byokChannelId); + byokChannelId = null; + }); + } + } + + // ── Scoping isolation: BYOK should NOT appear in admin/configs ── + if (T.user.role === 'admin') { + await T.test('provider', 'isolation', 'BYOK not in /admin/configs', async function () { + var d = await T.apiGet('/admin/configs'); + var configs = d.configs || d.data || d; + if (Array.isArray(configs)) { + var leaked = configs.find(function (c) { return c.id === byokConfigId; }); + T.assert(!leaked, 'BYOK config leaked into global admin configs list'); + } + }); + } + + await T.test('provider', 'byok', 'DELETE /api-configs/:id', async function () { + await T.safeDelete('/api-configs/' + byokConfigId); + byokConfigId = null; + }); + + // After delete, should not appear in list + await T.test('provider', 'byok', 'GET /api-configs (deleted config gone)', async function () { + var d = await T.apiGet('/api-configs'); + var configs = d.configs || d.data || d; + if (Array.isArray(configs)) { + var ghost = configs.find(function (c) { return c.name && c.name.indexOf(tag) !== -1; }); + T.assert(!ghost, 'deleted BYOK config still in list'); + } + }); + } + + // Restore allow_user_byok to original value + if (byokPolicyWas !== null && T.user.role === 'admin') { + await T.test('provider', 'byok', 'restore allow_user_byok policy', async function () { + await T.apiPut('/admin/settings/allow_user_byok', { value: byokPolicyWas || 'false' }); + }); + } + }; +})(); diff --git a/surfaces/icd-test-runner/js/tier-security.js b/surfaces/icd-test-runner/js/tier-security.js new file mode 100644 index 0000000..66de728 --- /dev/null +++ b/surfaces/icd-test-runner/js/tier-security.js @@ -0,0 +1,719 @@ +/** + * ICD Test Runner — Security Tier + * Adversarial red-team tests: auth boundary, cross-tenant isolation, + * input validation, session security. + * + * REQUIRES: fixtures provisioned (3 users + team). + * + * Many of these tests are EXPECTED TO FIND REAL BUGS. The test names + * include severity tags: [P0] = must-fix before 1.0, + * [P1] = should-fix, [P2] = harden. + * + * 502 handling: a 502 means the request never reached the Go backend + * (Traefik/nginx returned it). This is NOT evidence of a security + * pass or fail — it is INCONCLUSIVE. Tests that receive 502 throw + * with a clear "INCONCLUSIVE (502)" prefix so they show as failures + * without being misread as security breaches. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + // ─── Helpers ────────────────────────────────────────────── + + function randomString(len) { + var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + var s = ''; + for (var i = 0; i < (len || 8); i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); + return s; + } + + /** + * Assert a status code indicates denial (401, 403, 404). + * 502 = INCONCLUSIVE (proxy ate the request, backend never saw it). + * Anything else (especially 200/201) = real security failure. + */ + function assertDenied(status, label) { + if (status === 502) { + throw new Error('INCONCLUSIVE (502): proxy returned 502 before request reached backend — ' + label); + } + if (status === 401 || status === 403 || status === 404) return; + throw new Error(label + ' — got HTTP ' + status); + } + + /** + * Assert a raw fetch() Response is a denial. + */ + function assertRawDenied(resp, label) { + if (resp.status === 502) { + throw new Error('INCONCLUSIVE (502): proxy returned 502 — ' + label + + '. Verify Traefik/nginx forwards unauthenticated requests to the Go backend.'); + } + if (resp.status === 401 || resp.status === 403 || resp.status === 404) return; + throw new Error(label + ' — got HTTP ' + resp.status); + } + + // ─── Main Entry ─────────────────────────────────────────── + + T.runSecurity = async function () { + if (!T.fixtures.ready) { + if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning'); + return; + } + + var testUser = T.getFixtureUser('-user'); + var teamAdmin = T.getFixtureUser('-tadmin'); + var testAdmin = T.getFixtureUser('-admin2'); + var adminToken = await T.getAuthToken(); + + if (!testUser || !testUser.token || !adminToken) { + T.results.push({ tier: 'security', domain: 'setup', name: 'SKIP — missing tokens', status: 'fail', duration: 0, + detail: 'Fixture users must be provisioned with valid tokens' }); + return; + } + + // ════════════════════════════════════════════════════════ + // 1. AUTH BOUNDARY + // ════════════════════════════════════════════════════════ + + await T.test('security', 'auth-boundary', '[P0] deactivated user JWT still valid', async function () { + var tag = 'sec-deact-' + Date.now(); + var pw = 'T3st!Pass' + randomString(6); + var created = await T.apiPost('/admin/users', { + username: tag, password: pw, email: tag + '@test.local', role: 'user' + }); + T.assert(created && created.id, 'failed to create sacrificial user'); + T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); + var login = await T.publicPost('/auth/login', { login: tag, password: pw }); + T.assert(login.access_token, 'login failed'); + var victimToken = login.access_token; + + var pre = await T.authFetch(victimToken, 'GET', '/profile'); + T.assertStatus(pre, 200, 'pre-deactivate profile'); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false }); + + var post = await T.authFetch(victimToken, 'GET', '/profile'); + T.assert(post._status === 401 || post._status === 403, + 'CRITICAL: deactivated user JWT still accepted! got ' + post._status + + ' — middleware does not check is_active in DB'); + }); + + await T.test('security', 'auth-boundary', '[P0] demoted admin JWT retains admin access', async function () { + var tag = 'sec-demote-' + Date.now(); + var pw = 'T3st!Pass' + randomString(6); + var created = await T.apiPost('/admin/users', { + username: tag, password: pw, email: tag + '@test.local', role: 'admin' + }); + T.assert(created && created.id, 'failed to create admin user'); + T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); + var login = await T.publicPost('/auth/login', { login: tag, password: pw }); + T.assert(login.access_token, 'login failed'); + var adminJwt = login.access_token; + + var pre = await T.authFetch(adminJwt, 'GET', '/admin/stats'); + T.assertStatus(pre, 200, 'pre-demote admin stats'); + + await T.apiPut('/admin/users/' + created.id + '/role', { role: 'user' }); + + var post = await T.authFetch(adminJwt, 'GET', '/admin/stats'); + T.assert(post._status === 403 || post._status === 401, + 'CRITICAL: demoted user JWT still has admin access! got ' + post._status + + ' — role is baked into JWT, not checked against DB'); + }); + + await T.test('security', 'auth-boundary', '[P1] refresh token reuse after logout', async function () { + var tag = 'sec-refresh-' + Date.now(); + var pw = 'T3st!Pass' + randomString(6); + var created = await T.apiPost('/admin/users', { + username: tag, password: pw, email: tag + '@test.local', role: 'user' + }); + T.assert(created && created.id, 'failed to create user'); + T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); + var login = await T.publicPost('/auth/login', { login: tag, password: pw }); + T.assert(login.refresh_token, 'no refresh token'); + + await T.authFetch(login.access_token, 'POST', '/auth/logout', { refresh_token: login.refresh_token }); + + var reuse = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token }); + T.assert(reuse._status === 401 || reuse._status === 400, + 'revoked refresh token was accepted! got ' + reuse._status); + }); + + await T.test('security', 'auth-boundary', '[P1] refresh token rotation (old invalid after use)', async function () { + var tag = 'sec-rotate-' + Date.now(); + var pw = 'T3st!Pass' + randomString(6); + var created = await T.apiPost('/admin/users', { + username: tag, password: pw, email: tag + '@test.local', role: 'user' + }); + T.assert(created && created.id, 'failed to create user'); + T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); + var login = await T.publicPost('/auth/login', { login: tag, password: pw }); + var rt1 = login.refresh_token; + + var refreshed = await T.publicPost('/auth/refresh', { refresh_token: rt1 }); + T.assert(refreshed._status === 200 && refreshed.access_token, 'refresh failed'); + + var replay = await T.publicPost('/auth/refresh', { refresh_token: rt1 }); + T.assert(replay._status === 401 || replay._status === 400, + 'rotated-out refresh token still valid! got ' + replay._status); + }); + + await T.test('security', 'auth-boundary', '[P0] tampered JWT payload accepted', async function () { + var parts = testUser.token.split('.'); + T.assert(parts.length === 3, 'token is not 3-part JWT'); + try { + var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + payload.role = 'admin'; + var faked = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + var forgedToken = parts[0] + '.' + faked + '.' + parts[2]; + var d = await T.authFetch(forgedToken, 'GET', '/admin/stats'); + T.assert(d._status === 401, + 'CRITICAL: tampered JWT was accepted! got ' + d._status); + } catch (e) { + if (e.message && e.message.indexOf('CRITICAL') !== -1) throw e; + } + }); + + await T.test('security', 'auth-boundary', '[P0] JWT alg=none accepted', async function () { + var header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + var payload = btoa(JSON.stringify({ + user_id: testUser.id, email: testUser.username + '@test.local', role: 'admin', + exp: Math.floor(Date.now() / 1000) + 3600 + })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + var forged = header + '.' + payload + '.'; + var d = await T.authFetch(forged, 'GET', '/admin/stats'); + T.assert(d._status === 401, + 'CRITICAL: alg=none JWT was accepted! got ' + d._status); + }); + + await T.test('security', 'auth-boundary', '[P1] requests with no user_id context', async function () { + var d = await T.authFetch('eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiIn0.garbage', 'GET', '/profile'); + T.assert(d._status === 401, 'empty user_id token accepted, got ' + d._status); + }); + + await T.test('security', 'auth-boundary', '[P1] deactivated user cannot refresh', async function () { + var tag = 'sec-deact-ref-' + Date.now(); + var pw = 'T3st!Pass' + randomString(6); + var created = await T.apiPost('/admin/users', { + username: tag, password: pw, email: tag + '@test.local', role: 'user' + }); + T.assert(created && created.id, 'failed to create user'); + T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); + var login = await T.publicPost('/auth/login', { login: tag, password: pw }); + + await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false }); + + var d = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token }); + T.assert(d._status === 401, 'deactivated user was able to refresh! got ' + d._status); + }); + + // ════════════════════════════════════════════════════════ + // 2. CROSS-TENANT DATA ACCESS + // ════════════════════════════════════════════════════════ + + // ── Notes isolation ── + + var adminNote = null; + await T.test('security', 'cross-tenant', '[P0] user → read another user\'s note by ID', async function () { + var d = await T.apiPost('/notes', { title: 'sec-private-' + Date.now(), content: 'SECRET DATA' }); + T.assert(d && d.id, 'note creation failed'); + adminNote = d.id; + T.registerCleanup(function () { if (adminNote) return T.safeDelete('/notes/' + adminNote); }); + + var steal = await T.authFetch(testUser.token, 'GET', '/notes/' + adminNote); + assertDenied(steal._status, 'user can read another user\'s note'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → update another user\'s note', async function () { + if (!adminNote) { T.assert(false, 'depends on previous test'); return; } + var d = await T.authFetch(testUser.token, 'PUT', '/notes/' + adminNote, { title: 'HACKED' }); + assertDenied(d._status, 'user can update another user\'s note'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s note', async function () { + if (!adminNote) { T.assert(false, 'depends on previous test'); return; } + var d = await T.authFetch(testUser.token, 'DELETE', '/notes/' + adminNote); + assertDenied(d._status, 'user can delete another user\'s note'); + }); + + // ── Memory isolation (no POST /memories — system-extracted only) ── + + await T.test('security', 'cross-tenant', '[P1] user → GET /admin/memories/pending (expect deny)', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/admin/memories/pending'); + assertDenied(d._status, 'user can list admin pending memories'); + }); + + await T.test('security', 'cross-tenant', '[P1] user → PUT /memories/:fakeId (expect deny)', async function () { + var d = await T.authFetch(testUser.token, 'PUT', '/memories/00000000-0000-0000-0000-000000000000', { value: 'HACKED' }); + assertDenied(d._status, 'user can hit PUT /memories with fabricated ID'); + }); + + await T.test('security', 'cross-tenant', '[P1] user → DELETE /memories/:fakeId (expect deny)', async function () { + var d = await T.authFetch(testUser.token, 'DELETE', '/memories/00000000-0000-0000-0000-000000000000'); + assertDenied(d._status, 'user can hit DELETE /memories with fabricated ID'); + }); + + // ── Channel / message isolation ── + + var adminChannel = null; + await T.test('security', 'cross-tenant', '[P0] user → read another user\'s channel messages', async function () { + var ch = await T.apiPost('/channels', { title: 'sec-private-ch-' + Date.now(), type: 'direct' }); + T.assert(ch && ch.id, 'channel creation failed'); + adminChannel = ch.id; + T.registerCleanup(function () { if (adminChannel) return T.safeDelete('/channels/' + adminChannel); }); + + await T.apiPost('/channels/' + adminChannel + '/messages', { + role: 'user', content: 'TOP SECRET MESSAGE' + }); + + var steal = await T.authFetch(testUser.token, 'GET', '/channels/' + adminChannel + '/messages'); + assertDenied(steal._status, 'user can read another user\'s messages'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → POST message to another user\'s channel', async function () { + if (!adminChannel) { T.assert(false, 'depends on previous test'); return; } + var d = await T.authFetch(testUser.token, 'POST', '/channels/' + adminChannel + '/messages', { + role: 'user', content: 'INJECTED' + }); + assertDenied(d._status, 'user can inject messages into another user\'s channel'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → update another user\'s channel', async function () { + if (!adminChannel) { T.assert(false, 'depends on previous test'); return; } + var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { title: 'HACKED' }); + assertDenied(d._status, 'user can rename another user\'s channel'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → archive another user\'s channel', async function () { + if (!adminChannel) { T.assert(false, 'depends on previous test'); return; } + var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { is_archived: true }); + assertDenied(d._status, 'user can archive another user\'s channel'); + }); + + // ── BYOK config isolation ── + + await T.test('security', 'cross-tenant', '[P1] user → enumerate BYOK configs', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/api-configs'); + T.assertStatus(d, 200, 'api-configs'); + var arr = d.data || d.configs || []; + if (Array.isArray(arr)) { + arr.forEach(function (cfg) { + if (cfg.user_id) { + T.assert(cfg.user_id === testUser.id, + 'BYOK config user_id mismatch — leaking other user\'s config'); + } + }); + } + }); + + // ── Task isolation ── + + var adminTask = null; + await T.test('security', 'cross-tenant', '[P0] user → read another user\'s task', async function () { + try { + var d = await T.apiPost('/tasks', { + name: 'sec-task-' + Date.now(), task_type: 'scheduled', + schedule: '0 0 * * *', scope: 'personal', + prompt: 'test', model: 'test' + }); + adminTask = (d && d.id) ? d.id : (d && d.task && d.task.id) ? d.task.id : null; + if (adminTask) T.registerCleanup(function () { return T.safeDelete('/tasks/' + adminTask); }); + } catch (e) { /* may require permissions */ } + + if (!adminTask) { + var adm = await T.authFetch(testUser.token, 'GET', '/admin/tasks'); + assertDenied(adm._status, 'user can list admin tasks'); + return; + } + var steal = await T.authFetch(testUser.token, 'GET', '/tasks/' + adminTask); + assertDenied(steal._status, 'user can read another user\'s task'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s task', async function () { + if (!adminTask) { + var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/00000000-0000-0000-0000-000000000000'); + assertDenied(d._status, 'user can hit DELETE /tasks with fabricated ID'); + return; + } + var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/' + adminTask); + assertDenied(d._status, 'user can delete another user\'s task'); + }); + + // ── Workspace isolation ── + + var adminWs = null; + await T.test('security', 'cross-tenant', '[P0] user → read another user\'s workspace', async function () { + try { + var d = await T.apiPost('/workspaces', { + name: 'sec-ws-' + Date.now(), owner_type: 'user', owner_id: T.user.id + }); + if (d && d.id) { + adminWs = d.id; + T.registerCleanup(function () { return T.safeDelete('/workspaces/' + adminWs); }); + } + } catch (e) { /* may fail */ } + if (!adminWs) return; + + var steal = await T.authFetch(testUser.token, 'GET', '/workspaces/' + adminWs); + assertDenied(steal._status, 'user can read another user\'s workspace'); + }); + + // ── Cross-team access ── + + await T.test('security', 'cross-tenant', '[P1] user → access non-member team providers', async function () { + var otherTeam = null; + try { + var d = await T.apiPost('/admin/teams', { name: 'sec-other-team-' + Date.now(), description: 'isolation test' }); + otherTeam = d.id; + T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); }); + } catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; } + + var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/providers'); + assertDenied(steal._status, 'user can list providers for a team they are not in'); + }); + + await T.test('security', 'cross-tenant', '[P1] user → access non-member team personas', async function () { + var otherTeam = null; + try { + var d = await T.apiPost('/admin/teams', { name: 'sec-other-team2-' + Date.now(), description: 'isolation test' }); + otherTeam = d.id; + T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); }); + } catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; } + + var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/personas'); + assertDenied(steal._status, 'user can list personas for a team they are not in'); + }); + + await T.test('security', 'cross-tenant', '[P1] user → access non-member team members list', async function () { + var otherTeam = null; + try { + var d = await T.apiPost('/admin/teams', { name: 'sec-other-team3-' + Date.now(), description: 'isolation test' }); + otherTeam = d.id; + T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); }); + } catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; } + + var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/members'); + assertDenied(steal._status, 'user can list members of a team they are not in'); + }); + + // ── Project isolation ── + + var adminProject = null; + await T.test('security', 'cross-tenant', '[P0] user → read another user\'s personal project', async function () { + try { + var d = await T.apiPost('/projects', { name: 'sec-project-' + Date.now(), scope: 'personal' }); + if (d && d.id) { + adminProject = d.id; + T.registerCleanup(function () { return T.safeDelete('/projects/' + adminProject); }); + } + } catch (e) { /* may fail */ } + if (!adminProject) return; + + var steal = await T.authFetch(testUser.token, 'GET', '/projects/' + adminProject); + assertDenied(steal._status, 'user can read another user\'s project'); + }); + + await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s project', async function () { + if (!adminProject) return; + var d = await T.authFetch(testUser.token, 'DELETE', '/projects/' + adminProject); + assertDenied(d._status, 'user can delete another user\'s project'); + }); + + // ── Notification isolation ── + + await T.test('security', 'cross-tenant', '[P1] user → list only sees own notifications', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/notifications'); + T.assertStatus(d, 200, 'notifications'); + }); + + // ════════════════════════════════════════════════════════ + // 3. INPUT VALIDATION + // ════════════════════════════════════════════════════════ + + var sqliPayloads = [ + "' OR '1'='1", + "'; DROP TABLE users; --", + "' UNION SELECT id, password_hash, email, role, 'x', 'x' FROM users --", + "1; SELECT pg_sleep(5)--", + "' OR 1=1--" + ]; + + for (var si = 0; si < sqliPayloads.length; si++) { + await (async function (payload, idx) { + await T.test('security', 'input-validation', '[P0] SQLi in notes search (' + (idx + 1) + '/' + sqliPayloads.length + ')', async function () { + var d = await T.authFetch(testUser.token, 'GET', + '/notes?query=' + encodeURIComponent(payload)); + T.assert(d._status !== 500, + 'SQL injection caused server error (500)! Payload: ' + payload); + }); + })(sqliPayloads[si], si); + } + + await T.test('security', 'input-validation', '[P0] SQLi in channel search', async function () { + var d = await T.authFetch(testUser.token, 'GET', + '/channels?query=' + encodeURIComponent("' UNION SELECT * FROM users--")); + T.assert(d._status !== 500, 'SQL injection in channel search caused 500'); + }); + + await T.test('security', 'input-validation', '[P0] SQLi in admin user search', async function () { + var d = await T.authFetch(adminToken, 'GET', + '/admin/users?query=' + encodeURIComponent("' OR '1'='1")); + T.assert(d._status !== 500, 'SQL injection in admin user search caused 500'); + }); + + await T.test('security', 'input-validation', '[P2] XSS in channel title (stored)', async function () { + var ch = await T.apiPost('/channels', { title: '', type: 'direct' }); + if (ch && ch.id) { + T.registerCleanup(function () { return T.safeDelete('/channels/' + ch.id); }); + var readBack = await T.apiGet('/channels/' + ch.id); + T.assert(readBack._status !== 500, 'XSS channel title caused 500'); + } + }); + + await T.test('security', 'input-validation', '[P2] XSS in note content (stored)', async function () { + var n = await T.apiPost('/notes', { title: 'xss-test', content: '' }); + if (n && n.id) { + T.registerCleanup(function () { return T.safeDelete('/notes/' + n.id); }); + var readBack = await T.apiGet('/notes/' + n.id); + T.assert(readBack._status !== 500, 'XSS note content caused 500'); + } + }); + + await T.test('security', 'input-validation', '[P2] XSS in persona name (stored)', async function () { + try { + var p = await T.apiPost('/personas', { name: '">', scope: 'personal', model: 'test' }); + if (p && p.id) T.registerCleanup(function () { return T.safeDelete('/personas/' + p.id); }); + } catch (e) { /* server rejection is fine */ } + }); + + await T.test('security', 'input-validation', '[P2] oversized channel title (10KB)', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'A'.repeat(10240), type: 'direct' }); + if ((d._status === 200 || d._status === 201) && d.id) { + T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); }); + } + T.assert(d._status !== 500, 'oversized title caused 500'); + }); + + await T.test('security', 'input-validation', '[P2] oversized note content (5MB)', async function () { + try { + var d = await T.authFetch(testUser.token, 'POST', '/notes', { title: 'big', content: 'X'.repeat(5 * 1024 * 1024) }); + if (d && d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); }); + } catch (e) { /* rejection is fine */ } + }); + + await T.test('security', 'input-validation', '[P2] malformed JSON body', async function () { + var resp = await fetch(T.base + '/api/v1/channels', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: '{title: invalid, no-quotes}' + }); + if (resp.status === 502) { + throw new Error('INCONCLUSIVE (502): proxy returned 502 — may not forward malformed bodies to backend'); + } + T.assert(resp.status === 400, 'malformed JSON should return 400, got ' + resp.status); + }); + + await T.test('security', 'input-validation', '[P2] Content-Type mismatch (form data as JSON)', async function () { + var resp = await fetch(T.base + '/api/v1/channels', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + body: 'title=test&type=direct' + }); + if (resp.status === 502) { + throw new Error('INCONCLUSIVE (502): proxy returned 502 on content-type mismatch'); + } + T.assert(resp.status === 400 || resp.status === 415, + 'form data accepted on JSON endpoint, got ' + resp.status); + }); + + await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () { + var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' }); + try { + var d = await T.apiUpload('/admin/surfaces/install', blob, 'evil.surface'); + T.assert(d._status === 400 || d._status === 409, + 'path traversal surface accepted! got ' + d._status); + } catch (e) { + if (e.message.indexOf('502') !== -1) { + throw new Error('INCONCLUSIVE (502): proxy returned 502 on surface upload'); + } + T.assert(e.message.indexOf('400') !== -1 || e.message.indexOf('409') !== -1 || e.message.indexOf('415') !== -1, + 'unexpected error: ' + e.message); + } + }); + + await T.test('security', 'input-validation', '[P1] null byte in channel ID param', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/channels/abc%00def'); + T.assert(d._status !== 500, 'null byte in ID caused 500'); + }); + + await T.test('security', 'input-validation', '[P1] extremely long ID param', async function () { + var d = await T.authFetch(testUser.token, 'GET', '/channels/' + 'a'.repeat(4096)); + T.assert(d._status !== 500, 'extremely long ID caused 500'); + }); + + await T.test('security', 'input-validation', '[P2] unicode RTLO in channel title', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'normal\u202Eelif_gnol', type: 'direct' }); + if ((d._status === 200 || d._status === 201) && d.id) { + T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); }); + } + T.assert(d._status !== 500, 'RTLO character caused 500'); + }); + + // ════════════════════════════════════════════════════════ + // 4. SESSION SECURITY (unauthenticated access) + // Raw fetch() without auth headers. 502 = INCONCLUSIVE + // (proxy may not forward unauthenticated requests). + // ════════════════════════════════════════════════════════ + + await T.test('security', 'session', '[P0] unauthenticated → GET /channels (expect 401)', async function () { + var resp = await fetch(T.base + '/api/v1/channels', { method: 'GET', credentials: 'same-origin' }); + assertRawDenied(resp, 'unauthenticated access to /channels'); + }); + + await T.test('security', 'session', '[P0] unauthenticated → GET /profile (expect 401)', async function () { + var resp = await fetch(T.base + '/api/v1/profile', { method: 'GET', credentials: 'same-origin' }); + assertRawDenied(resp, 'unauthenticated access to /profile'); + }); + + await T.test('security', 'session', '[P0] unauthenticated → GET /admin/users (expect 401)', async function () { + var resp = await fetch(T.base + '/api/v1/admin/users', { method: 'GET', credentials: 'same-origin' }); + assertRawDenied(resp, 'unauthenticated access to admin'); + }); + + await T.test('security', 'session', '[P0] unauthenticated → POST /channels (expect 401)', async function () { + var resp = await fetch(T.base + '/api/v1/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ title: 'unauth-test', type: 'direct' }) + }); + assertRawDenied(resp, 'unauthenticated channel creation'); + }); + + await T.test('security', 'session', '[P1] cookie-only → attempt JWT-protected endpoints', async function () { + var d = await T.sessionGet('/notes'); + if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request'); + T.assert(d._status === 401, 'cookie-only access to /notes returned ' + d._status); + }); + + await T.test('security', 'session', '[P1] cookie-only → attempt admin endpoints', async function () { + var d = await T.sessionGet('/admin/stats'); + if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request'); + T.assert(d._status === 401, 'cookie-only access to /admin/stats returned ' + d._status); + }); + + // ════════════════════════════════════════════════════════ + // 5. PRIVILEGE ESCALATION ATTEMPTS + // ════════════════════════════════════════════════════════ + + await T.test('security', 'escalation', '[P0] user → PUT own role to admin', async function () { + var d = await T.authFetch(testUser.token, 'PUT', '/admin/users/' + testUser.id + '/role', { role: 'admin' }); + assertDenied(d._status, 'CRITICAL: user was able to self-promote to admin'); + }); + + await T.test('security', 'escalation', '[P0] user → POST /admin/users with admin role', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/admin/users', { + username: 'should-never-exist-' + Date.now(), password: 'Hack3d!xx', + email: 'hack@evil.com', role: 'admin' + }); + assertDenied(d._status, 'CRITICAL: non-admin created an admin user'); + }); + + if (teamAdmin && teamAdmin.token) { + await T.test('security', 'escalation', '[P0] teamAdmin → PUT /admin/settings', async function () { + var d = await T.authFetch(teamAdmin.token, 'PUT', '/admin/settings/allow_registration', { value: true }); + assertDenied(d._status, 'team admin can modify platform settings'); + }); + + await T.test('security', 'escalation', '[P0] teamAdmin → DELETE /admin/users/:id', async function () { + var d = await T.authFetch(teamAdmin.token, 'DELETE', '/admin/users/' + testUser.id); + assertDenied(d._status, 'CRITICAL: team admin can delete users'); + }); + + await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () { + var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {}); + if (d._status === 400) return; // bad upload format, not a security issue + assertDenied(d._status, 'team admin can install surfaces'); + }); + } + + await T.test('security', 'escalation', '[P0] user_id substitution in channel creation', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/channels', { + title: 'impersonation-test-' + Date.now(), type: 'direct', user_id: T.user.id + }); + if (d._status === 200 || d._status === 201) { + if (d.id) T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); }); + T.assert(!d.user_id || d.user_id === testUser.id, + 'CRITICAL: channel created as another user! owner=' + d.user_id); + } + }); + + await T.test('security', 'escalation', '[P0] user_id substitution in note creation', async function () { + var d = await T.authFetch(testUser.token, 'POST', '/notes', { + title: 'impersonation-' + Date.now(), content: 'test', user_id: T.user.id + }); + if (d._status === 200 || d._status === 201) { + if (d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); }); + if (d.user_id) { + T.assert(d.user_id === testUser.id, + 'CRITICAL: note created as another user! owner=' + d.user_id); + } + } + }); + + // ════════════════════════════════════════════════════════ + // 6. HEADER / TRANSPORT SECURITY + // ════════════════════════════════════════════════════════ + + await T.test('security', 'transport', '[P2] CORS allows wildcard origin', async function () { + var resp = await fetch(T.base + '/api/v1/health', { + method: 'OPTIONS', + headers: { 'Origin': 'https://evil.example.com', 'Access-Control-Request-Method': 'GET' } + }); + var acao = resp.headers.get('Access-Control-Allow-Origin'); + if (acao === '*') { + T.assert(false, 'CORS returns Access-Control-Allow-Origin: * — restrict in production'); + } + }); + + await T.test('security', 'transport', '[P2] auth endpoint rate limiting', async function () { + var statuses = []; + for (var i = 0; i < 10; i++) { + try { + var d = await T.publicPost('/auth/login', { login: 'nonexistent', password: 'wrong' }); + statuses.push(d._status); + } catch (e) { statuses.push('err'); } + } + var backendStatuses = statuses.filter(function (s) { return s !== 502; }); + if (backendStatuses.length === 0) { + throw new Error('INCONCLUSIVE (502): all login attempts returned 502 — cannot test rate limiting'); + } + var rateLimited = backendStatuses.some(function (s) { return s === 429; }); + if (!rateLimited) { + T.assert(false, 'no rate limiting on /auth/login after ' + backendStatuses.length + + ' attempts reaching backend — statuses: [' + backendStatuses.join(',') + ']'); + } + }); + + await T.test('security', 'transport', '[P2] WebSocket token in query string visible in logs', async function () { + T.assert(false, + 'INFO: WebSocket uses ?token= query param (JWT visible in server logs, proxy logs, browser history). ' + + 'Consider using a short-lived ticket exchange instead.'); + }); + }; + +})(); diff --git a/surfaces/icd-test-runner/js/tier-smoke.js b/surfaces/icd-test-runner/js/tier-smoke.js new file mode 100644 index 0000000..102f70a --- /dev/null +++ b/surfaces/icd-test-runner/js/tier-smoke.js @@ -0,0 +1,448 @@ +/** + * ICD Test Runner — Smoke Tier + * Read-only GET tests for every endpoint domain. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + T.runSmoke = async function () { + // -- Health -- + await T.test('smoke', 'utility', 'GET /health', async function () { + var d = await T.apiGet('/health'); + T.assert(d.status === 'ok', 'expected status "ok", got "' + d.status + '"'); + }); + + // -- Public Settings -- + await T.test('smoke', 'admin', 'GET /settings/public', async function () { + var d = await T.apiGet('/settings/public'); + T.assertHasKey(d, 'policies', 'public_settings'); + T.assert(typeof d.policies === 'object', 'policies should be object'); + }); + + // -- Profile -- + await T.test('smoke', 'profile', 'GET /profile', async function () { + var d = await T.apiGet('/profile'); + T.assertShape(d, T.S.profile, 'profile'); + T.assert(d.username.length > 0, 'username should be non-empty'); + }); + + // -- User Settings -- + await T.test('smoke', 'profile', 'GET /settings (user)', async function () { + var d = await T.apiGet('/settings'); + T.assertHasKey(d, 'settings', 'user_settings'); + }); + + // -- Models -- + await T.test('smoke', 'models', 'GET /models/enabled', async function () { + var d = await T.apiGet('/models/enabled'); + T.assertHasKey(d, 'data', '/models/enabled'); + T.assert(Array.isArray(d.data), 'data should be array'); + if (d.data.length > 0) { + T.assertShape(d.data[0], T.S.modelEnabled, 'data[0]'); + } + }); + + await T.test('smoke', 'models', 'GET /models/preferences', async function () { + var d = await T.apiGet('/models/preferences'); + T.assertHasKey(d, 'data', '/models/preferences'); + T.assert(Array.isArray(d.data), 'data should be array'); + if (d.data.length > 0) { + T.assertShape(d.data[0], T.S.modelPreference, 'data[0]'); + } + }); + + // -- Surfaces -- + await T.test('smoke', 'surfaces', 'GET /surfaces', async function () { + var d = await T.apiGet('/surfaces'); + T.assertHasKey(d, 'surfaces', '/surfaces'); + T.assertArrayOf(d.surfaces, T.S.surfaceNav, 'surfaces'); + // Our own surface should be in the list + var found = d.surfaces.some(function (s) { return s.id === 'icd-test-runner'; }); + T.assert(found, 'icd-test-runner not found in surfaces list'); + }); + + // -- Channels -- + await T.test('smoke', 'channels', 'GET /channels', async function () { + var d = await T.apiGet('/channels'); + // Paginated envelope or { data: [...] } + var arr = d.data || d.channels; + T.assert(Array.isArray(arr), 'expected data array from /channels'); + }); + + // -- Personas -- + await T.test('smoke', 'personas', 'GET /personas', async function () { + var d = await T.apiGet('/personas'); + T.assertHasKey(d, 'data', '/personas'); + T.assert(Array.isArray(d.data), 'personas should be array'); + }); + + // -- Persona Groups -- + await T.test('smoke', 'personas', 'GET /persona-groups', async function () { + var d = await T.apiGet('/persona-groups'); + T.assertHasKey(d, 'data', '/persona-groups'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Knowledge Bases -- + await T.test('smoke', 'knowledge', 'GET /knowledge-bases', async function () { + var d = await T.apiGet('/knowledge-bases'); + T.assertHasKey(d, 'data', '/knowledge-bases'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('smoke', 'knowledge', 'GET /knowledge-bases-discoverable', async function () { + var d = await T.apiGet('/knowledge-bases-discoverable'); + T.assertHasKey(d, 'data', '/kb-discoverable'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Notes -- + await T.test('smoke', 'notes', 'GET /notes', async function () { + var d = await T.apiGet('/notes'); + // Paginated or { data: [...] } + var arr = d.data || d.notes; + T.assert(Array.isArray(arr), 'expected array from /notes'); + }); + + await T.test('smoke', 'notes', 'GET /notes/folders', async function () { + var d = await T.apiGet('/notes/folders'); + T.assertHasKey(d, 'folders', '/notes/folders'); + T.assert(Array.isArray(d.folders), 'folders should be array'); + }); + + await T.test('smoke', 'notes', 'GET /notes/graph', async function () { + var d = await T.apiGet('/notes/graph'); + T.assertHasKey(d, 'nodes', '/notes/graph'); + T.assertHasKey(d, 'edges', '/notes/graph'); + T.assert(Array.isArray(d.nodes), 'nodes should be array'); + T.assert(Array.isArray(d.edges), 'edges should be array'); + }); + + // -- Projects -- + await T.test('smoke', 'projects', 'GET /projects', async function () { + var d = await T.apiGet('/projects'); + T.assertHasKey(d, 'data', '/projects'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Memories -- + await T.test('smoke', 'memory', 'GET /memories', async function () { + var d = await T.apiGet('/memories'); + T.assertHasKey(d, 'data', '/memories'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('smoke', 'memory', 'GET /memories/count', async function () { + var d = await T.apiGet('/memories/count'); + T.assertHasKey(d, 'active', '/memories/count'); + T.assertHasKey(d, 'pending', '/memories/count'); + T.assert(typeof d.active === 'number', 'active should be number'); + T.assert(typeof d.pending === 'number', 'pending should be number'); + }); + + // -- Notifications -- + await T.test('smoke', 'notifications', 'GET /notifications', async function () { + var d = await T.apiGet('/notifications'); + T.assertHasKey(d, 'data', '/notifications'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assertHasKey(d, 'total', '/notifications'); + T.assert(typeof d.total === 'number', 'total should be number'); + }); + + await T.test('smoke', 'notifications', 'GET /notifications/unread-count', async function () { + var d = await T.apiGet('/notifications/unread-count'); + T.assertHasKey(d, 'count', '/notifications/unread-count'); + T.assert(typeof d.count === 'number', 'count should be number'); + }); + + await T.test('smoke', 'notifications', 'GET /notifications/preferences', async function () { + var d = await T.apiGet('/notifications/preferences'); + T.assertHasKey(d, 'data', '/notifications/preferences'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Extensions -- + await T.test('smoke', 'extensions', 'GET /extensions', async function () { + var d = await T.apiGet('/extensions'); + T.assertHasKey(d, 'data', '/extensions'); + T.assert(Array.isArray(d.data), 'extensions should be array'); + }); + + await T.test('smoke', 'extensions', 'GET /extensions/tools', async function () { + var d = await T.apiGet('/extensions/tools'); + T.assertHasKey(d, 'data', '/extensions/tools'); + T.assert(Array.isArray(d.data), 'tools should be array'); + }); + + // -- Tools -- + await T.test('smoke', 'channels', 'GET /tools', async function () { + var d = await T.apiGet('/tools'); + T.assertHasKey(d, 'tools', '/tools'); + T.assert(Array.isArray(d.tools), 'tools should be array'); + }); + + // -- Provider Configs (BYOK) -- + await T.test('smoke', 'providers', 'GET /api-configs', async function () { + var d = await T.apiGet('/api-configs'); + T.assertHasKey(d, 'configs', '/api-configs'); + T.assert(Array.isArray(d.configs), 'configs should be array'); + }); + + // -- Teams -- + await T.test('smoke', 'teams', 'GET /teams/mine', async function () { + var d = await T.apiGet('/teams/mine'); + T.assertHasKey(d, 'data', '/teams/mine'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Groups -- + await T.test('smoke', 'teams', 'GET /groups/mine', async function () { + var d = await T.apiGet('/groups/mine'); + T.assertHasKey(d, 'data', '/groups/mine'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Tasks -- + await T.test('smoke', 'tasks', 'GET /tasks', async function () { + var d = await T.apiGet('/tasks'); + T.assertHasKey(d, 'data', '/tasks'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Workspaces -- + await T.test('smoke', 'workspaces', 'GET /workspaces', async function () { + var d = await T.apiGet('/workspaces'); + T.assertHasKey(d, 'data', '/workspaces'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Folders -- + await T.test('smoke', 'channels', 'GET /folders', async function () { + var d = await T.apiGet('/folders'); + // May be { data: [...] } or { folders: [...] } + var arr = d.data || d.folders; + T.assert(arr === undefined || Array.isArray(arr), 'expected array from /folders'); + }); + + // -- Files -- + await T.test('smoke', 'channels', 'GET /files', async function () { + var d = await T.apiGet('/files'); + T.assertHasKey(d, 'files', '/files'); + T.assert(Array.isArray(d.files), 'files should be array'); + T.assert(typeof d.total === 'number', 'expected total count'); + }); + + // -- Usage -- + await T.test('smoke', 'admin', 'GET /usage', async function () { + var d = await T.apiGet('/usage'); + // personal usage — may have totals, results + T.assert(typeof d === 'object', 'expected object from /usage'); + }); + + // -- Workflow Assignments -- + await T.test('smoke', 'workflows', 'GET /workflow-assignments/mine', async function () { + var d = await T.apiGet('/workflow-assignments/mine'); + // May be { data: [...] } or { assignments: [...] } + var arr = d.data || d.assignments; + T.assert(arr === undefined || Array.isArray(arr), 'expected array from /workflow-assignments/mine'); + }); + + // -- Workflows -- + await T.test('smoke', 'workflows', 'GET /workflows', async function () { + var d = await T.apiGet('/workflows'); + T.assertHasKey(d, 'data', '/workflows'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Git Credentials -- + await T.test('smoke', 'workspaces', 'GET /git-credentials', async function () { + var d = await T.apiGet('/git-credentials'); + T.assertHasKey(d, 'data', '/git-credentials'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + // -- Presence -- + await T.test('smoke', 'channels', 'POST /presence/heartbeat', async function () { + var d = await T.apiPost('/presence/heartbeat', {}); + T.assert(d.ok === true, 'expected { ok: true }'); + }); + + await T.test('smoke', 'channels', 'GET /presence (query)', async function () { + var d = await T.apiGet('/presence?users=' + encodeURIComponent(T.user.id)); + T.assertHasKey(d, 'presence', '/presence'); + T.assert(typeof d.presence === 'object', 'presence should be object'); + // After heartbeat, our own user should be online + if (d.presence[T.user.id]) { + T.assert(d.presence[T.user.id] === 'online', 'expected self online after heartbeat'); + } + }); + + // -- Admin routes (only if admin) -- + if (T.user.role === 'admin') { + await T.test('smoke', 'admin', 'GET /admin/stats', async function () { + var d = await T.apiGet('/admin/stats'); + T.assertHasKey(d, 'users', '/admin/stats'); + T.assertHasKey(d, 'channels', '/admin/stats'); + T.assertHasKey(d, 'messages', '/admin/stats'); + }); + + await T.test('smoke', 'admin', 'GET /admin/users', async function () { + var d = await T.apiGet('/admin/users'); + T.assertHasKey(d, 'users', '/admin/users'); + T.assert(Array.isArray(d.users), 'users should be array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/settings', async function () { + var d = await T.apiGet('/admin/settings'); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('smoke', 'admin', 'GET /admin/configs', async function () { + var d = await T.apiGet('/admin/configs'); + T.assertHasKey(d, 'configs', '/admin/configs'); + T.assert(Array.isArray(d.configs), 'configs should be array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/models', async function () { + var d = await T.apiGet('/admin/models'); + T.assertHasKey(d, 'models', '/admin/models'); + T.assert(Array.isArray(d.models), 'models should be array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/personas', async function () { + var d = await T.apiGet('/admin/personas'); + T.assertHasKey(d, 'data', '/admin/personas'); + }); + + await T.test('smoke', 'admin', 'GET /admin/teams', async function () { + var d = await T.apiGet('/admin/teams'); + T.assertHasKey(d, 'data', '/admin/teams'); + }); + + await T.test('smoke', 'admin', 'GET /admin/groups', async function () { + var d = await T.apiGet('/admin/groups'); + T.assertHasKey(d, 'data', '/admin/groups'); + }); + + await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () { + var d = await T.apiGet('/admin/surfaces'); + T.assertHasKey(d, 'surfaces', '/admin/surfaces'); + T.assert(Array.isArray(d.surfaces), 'surfaces should be array'); + if (d.surfaces.length > 0) { + T.assertShape(d.surfaces[0], T.S.surfaceAdmin, 'surfaces[0]'); + } + }); + + await T.test('smoke', 'admin', 'GET /admin/providers/health', async function () { + var d = await T.apiGet('/admin/providers/health'); + T.assertHasKey(d, 'data', '/admin/providers/health'); + }); + + await T.test('smoke', 'admin', 'GET /admin/capability-overrides', async function () { + var d = await T.apiGet('/admin/capability-overrides'); + T.assertHasKey(d, 'data', '/admin/capability-overrides'); + }); + + await T.test('smoke', 'admin', 'GET /admin/routing/policies', async function () { + var d = await T.apiGet('/admin/routing/policies'); + T.assertHasKey(d, 'data', '/admin/routing/policies'); + }); + + await T.test('smoke', 'admin', 'GET /admin/permissions', async function () { + var d = await T.apiGet('/admin/permissions'); + T.assertHasKey(d, 'permissions', '/admin/permissions'); + }); + + await T.test('smoke', 'admin', 'GET /admin/roles', async function () { + var d = await T.apiGet('/admin/roles'); + // Returns a raw JSONMap of role configs (e.g. { chat: {...}, summary: {...} }) + T.assert(typeof d === 'object' && d !== null, 'expected object'); + }); + + await T.test('smoke', 'admin', 'GET /admin/vault/status', async function () { + var d = await T.apiGet('/admin/vault/status'); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('smoke', 'admin', 'GET /admin/storage/status', async function () { + var d = await T.apiGet('/admin/storage/status'); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('smoke', 'admin', 'GET /admin/audit', async function () { + var d = await T.apiGet('/admin/audit'); + T.assertHasKey(d, 'data', '/admin/audit'); + T.assert(Array.isArray(d.data), 'data should be array'); + T.assertHasKey(d, 'total', '/admin/audit'); + T.assertHasKey(d, 'page', '/admin/audit'); + T.assertHasKey(d, 'per_page', '/admin/audit'); + }); + + await T.test('smoke', 'admin', 'GET /admin/audit/actions', async function () { + var d = await T.apiGet('/admin/audit/actions'); + T.assertHasKey(d, 'actions', '/admin/audit/actions'); + }); + + await T.test('smoke', 'admin', 'GET /admin/usage', async function () { + var d = await T.apiGet('/admin/usage'); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('smoke', 'admin', 'GET /admin/pricing', async function () { + var d = await T.apiGet('/admin/pricing'); + // Returns a bare array (no envelope) + T.assert(Array.isArray(d), '/admin/pricing should return array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/provider-types', async function () { + var d = await T.apiGet('/admin/provider-types'); + T.assertHasKey(d, 'data', '/admin/provider-types'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/tasks', async function () { + var d = await T.apiGet('/admin/tasks'); + T.assertHasKey(d, 'data', '/admin/tasks'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + + await T.test('smoke', 'admin', 'GET /admin/projects', async function () { + var d = await T.apiGet('/admin/projects'); + T.assertHasKey(d, 'data', '/admin/projects'); + }); + + await T.test('smoke', 'admin', 'GET /admin/channels/archived', async function () { + var d = await T.apiGet('/admin/channels/archived'); + T.assertHasKey(d, 'channels', '/admin/channels/archived'); + T.assert(Array.isArray(d.channels), 'channels should be array'); + T.assertHasKey(d, 'total', '/admin/channels/archived'); + T.assertHasKey(d, 'retention_mode', '/admin/channels/archived'); + }); + + await T.test('smoke', 'admin', 'GET /admin/storage/orphans', async function () { + var d = await T.apiGet('/admin/storage/orphans'); + T.assertHasKey(d, 'count', '/admin/storage/orphans'); + }); + + await T.test('smoke', 'admin', 'GET /admin/settings/:key (banner)', async function () { + var d = await T.apiGet('/admin/settings/banner'); + T.assertHasKey(d, 'key', '/admin/settings/:key'); + T.assertHasKey(d, 'value', '/admin/settings/:key'); + T.assert(d.key === 'banner', 'key should be "banner"'); + }); + + await T.test('smoke', 'admin', 'GET /admin/storage/extraction', async function () { + var d = await T.apiGet('/admin/storage/extraction'); + T.assert(typeof d === 'object', 'expected object'); + }); + + await T.test('smoke', 'memory', 'GET /admin/memories/pending', async function () { + var d = await T.apiGet('/admin/memories/pending'); + T.assertHasKey(d, 'data', '/admin/memories/pending'); + T.assert(Array.isArray(d.data), 'data should be array'); + }); + } + }; +})(); diff --git a/surfaces/icd-test-runner/js/ui.js b/surfaces/icd-test-runner/js/ui.js new file mode 100644 index 0000000..9205643 --- /dev/null +++ b/surfaces/icd-test-runner/js/ui.js @@ -0,0 +1,704 @@ +/** + * ICD Test Runner — UI + * Render functions, suite dispatcher, provider setup panel, fixtures panel, export. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + var $ = T.$; + var esc = T.esc; + + // ─── Provider Setup UI Panel ────────────────────────────── + + T.renderProviderSetup = function () { + if (!T.el.providerSetup) return; + T.el.providerSetup.innerHTML = ''; + + var cardStyle = { + background: 'var(--bg-surface)', border: '1px solid var(--border)', + borderRadius: 'var(--radius-lg)', padding: '16px', marginBottom: '0' + }; + + var card = $('div', { style: cardStyle }); + + // Header row + var hdr = $('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' } }, [ + $('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)' } }, 'Provider Test Setup'), + T.providerSetup.configured + ? $('span', { className: 'badge badge-success', style: { fontSize: '11px' } }, + esc(T.providerSetup.provider) + ' configured') + : $('span', { style: { fontSize: '11px', color: 'var(--text-3)' } }, 'Not configured — optional') + ]); + card.appendChild(hdr); + + // Input row + var inputRow = $('div', { style: { display: 'flex', gap: '10px', alignItems: 'flex-end', flexWrap: 'wrap' } }); + + // Provider select + var selWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }, [ + $('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'Provider') + ]); + var sel = $('select', { + style: { + background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)', + borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '13px', fontFamily: 'var(--font)', + minWidth: '140px' + } + }); + ['openai', 'anthropic', 'venice', 'openrouter'].forEach(function (p) { + var opt = $('option', { value: p }, p); + if (p === T.providerSetup.provider) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', function () { T.providerSetup.provider = sel.value; }); + selWrap.appendChild(sel); + inputRow.appendChild(selWrap); + + // API Key input + var keyWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1', minWidth: '240px' } }, [ + $('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'API Key') + ]); + var keyInput = $('input', { + type: 'password', + placeholder: 'sk-... / anthropic-... / paste key', + style: { + background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)', + borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '13px', fontFamily: 'var(--mono)', + width: '100%' + } + }); + keyInput.value = T.providerSetup.apiKey; + keyInput.addEventListener('input', function () { T.providerSetup.apiKey = keyInput.value; }); + keyWrap.appendChild(keyInput); + inputRow.appendChild(keyWrap); + + // Endpoint override (collapsed by default) + var epWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '200px' } }, [ + $('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'Endpoint (optional)') + ]); + var epInput = $('input', { + type: 'text', + placeholder: T.PROVIDER_DEFAULTS[T.providerSetup.provider] || 'default', + style: { + background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)', + borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '12px', fontFamily: 'var(--mono)', + width: '100%' + } + }); + epInput.value = T.providerSetup.endpoint; + epInput.addEventListener('input', function () { T.providerSetup.endpoint = epInput.value; }); + epWrap.appendChild(epInput); + inputRow.appendChild(epWrap); + + // Configure button + var cfgBtn = $('button', { + className: T.providerSetup.configured ? 'btn-ghost' : 'btn-primary', + style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' }, + onClick: function () { + if (!T.providerSetup.apiKey) { + if (typeof UI !== 'undefined') UI.toast('Paste an API key first', 'warning'); + return; + } + T.providerSetup.configured = true; + T.renderProviderSetup(); + T.renderControls(); + if (typeof UI !== 'undefined') UI.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success'); + } + }, T.providerSetup.configured ? 'Reconfigure' : 'Configure'); + inputRow.appendChild(cfgBtn); + + if (T.providerSetup.configured) { + var clearBtn = $('button', { + className: 'btn-ghost', + style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' }, + onClick: function () { + T.providerSetup.apiKey = ''; + T.providerSetup.configured = false; + T.renderProviderSetup(); + T.renderControls(); + } + }, 'Clear Key'); + inputRow.appendChild(clearBtn); + } + + card.appendChild(inputRow); + + // Security note + card.appendChild($('div', { + style: { fontSize: '11px', color: 'var(--text-3)', marginTop: '8px', fontStyle: 'italic' } + }, 'Key stays in browser memory only — never persisted, cleaned up on page close. Creates temporary configs and deletes on teardown.')); + + T.el.providerSetup.appendChild(card); + } + + + + T.renderShell = function () { + T.mount.innerHTML = ''; + T.mount.style.padding = '24px 32px'; + T.mount.style.maxWidth = '1000px'; + T.mount.style.margin = '0 auto'; + + // Header + var hdr = $('div', { style: { marginBottom: '24px' } }, [ + $('h1', { style: { fontSize: '22px', fontWeight: '700', color: 'var(--text)', margin: '0 0 4px 0' } }, + 'ICD Test Runner'), + $('div', { style: { fontSize: '13px', color: 'var(--text-3)' } }, + 'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(T.user.username) + (T.user.role === 'admin' ? ' (admin)' : ' (user)')) + ]); + T.mount.appendChild(hdr); + + // Controls + T.el.controls = $('div', { style: { display: 'flex', gap: '10px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' } }); + T.mount.appendChild(T.el.controls); + + // Fixtures panel (admin only) + if (T.user.role === 'admin') { + T.el.fixtures = $('div', { style: { marginBottom: '16px' } }); + T.mount.appendChild(T.el.fixtures); + } + + // Provider setup panel + T.el.providerSetup = $('div', { style: { marginBottom: '16px' } }); + T.mount.appendChild(T.el.providerSetup); + + // Progress + T.el.progress = $('div', { style: { marginBottom: '16px' } }); + T.mount.appendChild(T.el.progress); + + // Summary + T.el.summary = $('div', { style: { marginBottom: '20px' } }); + T.mount.appendChild(T.el.summary); + + // Detail table + T.el.detail = $('div', {}); + T.mount.appendChild(T.el.detail); + + T.renderControls(); + T.renderFixtures(); + T.renderProviderSetup(); + } + + T.renderControls = function () { + T.el.controls.innerHTML = ''; + var btnSmoke = $('button', { + className: 'btn-primary', + onClick: function () { T.runSuite('smoke'); } + }, 'Smoke'); + var btnCrud = $('button', { + className: 'btn-secondary', + onClick: function () { T.runSuite('crud'); } + }, 'CRUD'); + var btnAll = $('button', { + className: 'btn-primary', + onClick: function () { T.runSuite('all'); } + }, 'Run All'); + var btnClear = $('button', { + className: 'btn-ghost', + onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); } + }, 'Clear'); + T.el.controls.appendChild(btnSmoke); + T.el.controls.appendChild(btnCrud); + + // AuthZ button — only useful if admin (needs fixtures) + if (T.user.role === 'admin') { + var btnAuthz = $('button', { + className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost', + style: { opacity: T.fixtures.ready ? '1' : '0.5' }, + onClick: function () { T.runSuite('authz'); } + }, 'AuthZ'); + T.el.controls.appendChild(btnAuthz); + + var btnSecurity = $('button', { + className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost', + style: { + opacity: T.fixtures.ready ? '1' : '0.5', + borderColor: T.fixtures.ready ? 'var(--danger)' : undefined, + color: T.fixtures.ready ? 'var(--danger)' : undefined + }, + onClick: function () { T.runSuite('security'); } + }, 'Security'); + T.el.controls.appendChild(btnSecurity); + } + + // Provider tier button — needs provider setup + var btnProv = $('button', { + className: T.providerSetup.configured ? 'btn-secondary' : 'btn-ghost', + style: { opacity: T.providerSetup.configured ? '1' : '0.5' }, + onClick: function () { T.runSuite('provider'); } + }, 'Providers'); + T.el.controls.appendChild(btnProv); + + T.el.controls.appendChild(btnAll); + T.el.controls.appendChild(btnClear); + + // Separator + T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } })); + + // Export buttons + var btnCopy = $('button', { + className: 'btn-ghost', + onClick: function () { T.exportReport('clipboard'); } + }, 'Copy Report'); + var btnDownload = $('button', { + className: 'btn-ghost', + onClick: function () { T.exportReport('download'); } + }, 'Download'); + T.el.controls.appendChild(btnCopy); + T.el.controls.appendChild(btnDownload); + + // Fixture controls (admin) + if (T.user.role === 'admin') { + T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } })); + if (!T.fixtures.ready) { + T.el.controls.appendChild($('button', { + className: 'btn-secondary', + onClick: async function () { await T.provisionFixtures(); T.renderControls(); } + }, 'Provision Test Users')); + } else { + T.el.controls.appendChild($('button', { + className: 'btn-ghost', + style: { color: 'var(--danger)' }, + onClick: async function () { await T.teardownFixtures(); T.renderControls(); } + }, 'Tear Down Fixtures')); + } + } + } + + // ─── Fixtures Panel ───────────────────────────────────────── + + T.renderFixtures = function () { + if (!T.el.fixtures) return; + T.el.fixtures.innerHTML = ''; + if (!T.fixtures.ready) return; + + var panel = $('div', { + style: { + background: 'var(--bg-surface)', border: '1px solid var(--border)', + borderRadius: 'var(--radius-lg)', padding: '14px 18px', + borderLeft: '3px solid var(--accent)' + } + }); + + panel.appendChild($('div', { + style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '10px' } + }, 'Test Fixtures')); + + // Users table + var tbl = $('table', { + style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px', fontFamily: 'var(--mono)' } + }); + + var hdr = $('tr', { style: { borderBottom: '1px solid var(--border)' } }); + ['Role', 'Username', 'Password', 'Token', 'ID'].forEach(function (h) { + hdr.appendChild($('th', { style: { textAlign: 'left', padding: '4px 8px', color: 'var(--text-3)', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.5px' } }, h)); + }); + tbl.appendChild(hdr); + + T.fixtures.users.forEach(function (u) { + var tr = $('tr', { style: { borderBottom: '1px solid var(--border)' } }); + tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--accent)' } }, u.role)); + tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--text)' } }, u.username)); + + // Password with copy button + var pwTd = $('td', { style: { padding: '4px 8px' } }); + var pwSpan = $('span', { style: { color: 'var(--warning)', cursor: 'pointer' }, title: 'Click to copy' }, u.password); + pwSpan.addEventListener('click', function () { + navigator.clipboard.writeText(u.password); + if (typeof UI !== 'undefined') UI.toast('Password copied', 'info'); + }); + pwTd.appendChild(pwSpan); + tr.appendChild(pwTd); + + // Token status + var tokenStatus = u.token ? 'yes' : 'FAILED'; + var tokenColor = u.token ? 'var(--success)' : 'var(--danger)'; + tr.appendChild($('td', { style: { padding: '4px 8px', color: tokenColor } }, tokenStatus)); + + // ID (truncated) + tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--text-3)' } }, (u.id || '').substring(0, 8) + '…')); + + tbl.appendChild(tr); + }); + panel.appendChild(tbl); + + // Team/group info + var meta = []; + if (T.fixtures.team) meta.push('Team: ' + T.fixtures.team.name + ' (' + T.fixtures.team.id.substring(0, 8) + '…)'); + if (T.fixtures.group) meta.push('Group: ' + T.fixtures.group.name + ' (' + T.fixtures.group.id.substring(0, 8) + '…)'); + if (meta.length > 0) { + panel.appendChild($('div', { + style: { marginTop: '8px', fontSize: '11px', color: 'var(--text-3)' } + }, meta.join(' · '))); + } + + T.el.fixtures.appendChild(panel); + } + + // ─── Export ───────────────────────────────────────────────── + + function buildReport() { + var now = new Date().toISOString(); + var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length; + var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length; + var total = T.results.length; + var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0); + + var lines = []; + lines.push('=== Chat Switchboard ICD Test Report ==='); + lines.push('Generated: ' + now); + lines.push('Platform: v' + (T.manifest.version || '0.28.0')); + lines.push('User: ' + T.user.username + ' (' + T.user.role + ')'); + lines.push('URL: ' + location.href); + lines.push(''); + lines.push('--- Summary ---'); + lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + ' Rate: ' + (total > 0 ? Math.round((pass / total) * 100) : 0) + '% Duration: ' + totalMs + 'ms'); + + // Critical failures callout + var criticals = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }); + if (criticals.length > 0) { + lines.push(''); + lines.push('!!! ' + criticals.length + ' CRITICAL SECURITY FAILURE(S) !!!'); + criticals.forEach(function (r) { + lines.push(' → [' + r.tier + '/' + r.domain + '] ' + r.name); + lines.push(' ' + r.detail); + }); + } + + // Fixture info + if (T.fixtures.ready) { + lines.push(''); + lines.push('--- Test Fixtures ---'); + T.fixtures.users.forEach(function (u) { + lines.push(' ' + pad(u.role, 8) + pad(u.username, 40) + (u.token ? 'token:OK' : 'token:FAIL')); + }); + if (T.fixtures.team) lines.push(' Team: ' + T.fixtures.team.name + ' (' + T.fixtures.team.id + ')'); + if (T.fixtures.group) lines.push(' Group: ' + T.fixtures.group.name + ' (' + T.fixtures.group.id + ')'); + } + + // Provider config info + if (T.providerSetup.configured) { + lines.push(''); + lines.push('--- Provider Setup ---'); + lines.push(' Type: ' + T.providerSetup.provider); + lines.push(' Endpoint: ' + (T.providerSetup.endpoint || T.PROVIDER_DEFAULTS[T.providerSetup.provider] || 'default')); + lines.push(' Key: ' + (T.providerSetup.apiKey ? T.providerSetup.apiKey.slice(0, 8) + '...' : 'none')); + } + lines.push(''); + + // Domain breakdown + var domains = {}; + T.results.forEach(function (r) { + var k = r.domain; + if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 }; + domains[k].total++; + domains[k].ms += r.duration; + if (r.status === 'pass') domains[k].pass++; + else domains[k].fail++; + }); + + lines.push('--- By Domain ---'); + Object.keys(domains).forEach(function (d) { + var s = domains[d]; + var flag = s.fail > 0 ? 'FAIL' : 'OK'; + lines.push(' ' + pad(d, 16) + pad(flag, 6) + pad(s.pass + '/' + s.total, 8) + s.ms + 'ms'); + }); + lines.push(''); + + // Failures only (if any) + var failures = T.results.filter(function (r) { return r.status === 'fail'; }); + if (failures.length > 0) { + lines.push('--- Failures (' + failures.length + ') ---'); + failures.forEach(function (r, i) { + lines.push(' ' + (i + 1) + '. [' + r.tier + '/' + r.domain + '] ' + r.name); + lines.push(' ' + r.detail); + }); + lines.push(''); + } + + // Full detail table + lines.push('--- Full Results ---'); + lines.push(pad('STATUS', 8) + pad('TIER', 8) + pad('DOMAIN', 16) + pad('MS', 6) + 'TEST'); + lines.push(repeat('-', 90)); + T.results.forEach(function (r) { + var statusStr = r.status === 'pass' ? 'PASS' : 'FAIL'; + var line = pad(statusStr, 8) + pad(r.tier, 8) + pad(r.domain, 16) + pad(String(r.duration), 6) + r.name; + if (r.status === 'fail' && r.detail) { + line += '\n' + repeat(' ', 38) + '↳ ' + r.detail; + } + lines.push(line); + }); + lines.push(''); + lines.push('=== END ==='); + + return lines.join('\n'); + } + + function pad(s, w) { + s = String(s); + while (s.length < w) s += ' '; + return s; + } + + function repeat(ch, n) { + var s = ''; + for (var i = 0; i < n; i++) s += ch; + return s; + } + + T.exportReport = function (mode) { + if (T.results.length === 0) { + if (typeof UI !== 'undefined') UI.toast('No T.results to export — run a suite first', 'warning'); + return; + } + + var text = buildReport(); + + if (mode === 'clipboard') { + navigator.clipboard.writeText(text).then(function () { + if (typeof UI !== 'undefined') UI.toast('Report copied to clipboard', 'success'); + }).catch(function () { + // Fallback: select-all textarea + clipboardFallback(text); + }); + } else if (mode === 'download') { + var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + var filename = 'icd-report-' + ts + '.txt'; + var blob = new Blob([text], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + if (typeof UI !== 'undefined') UI.toast('Downloaded ' + filename, 'success'); + } + } + + function clipboardFallback(text) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px;top:0;'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* give up */ } + document.body.removeChild(ta); + if (typeof UI !== 'undefined') UI.toast('Report copied (fallback)', 'info'); + } + + + + T.runSuite = async function (which) { + if (T.running) return; + if (which === 'authz' && !T.fixtures.ready) { + if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); + return; + } + if (which === 'security' && !T.fixtures.ready) { + if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); + return; + } + if (which === 'provider' && !T.providerSetup.configured) { + if (typeof UI !== 'undefined') UI.toast('Configure a provider (type + API key) in the panel above', 'warning'); + return; + } + T.running = true; + T.results = []; + + // Capture auth token before any test (needed for DELETE fallback) + await T.captureAuthToken(); + + T.renderProgress(); + T.renderSummary(); + T.renderDetail(); + + try { + if (which === 'smoke' || which === 'all') await T.runSmoke(); + if (which === 'crud' || which === 'all') await T.runCrud(); + if (which === 'authz' || (which === 'all' && T.fixtures.ready)) await T.runAuthz(); + if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity(); + if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders(); + } catch (e) { + T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) }); + } + + // Always T.cleanup (CRUD-created resources, not fixtures) + await T.runCleanup(); + + T.running = false; + T.renderSummary(); + T.renderDetail(); + T.renderProgress(); + if (typeof UI !== 'undefined') { + var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length; + var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length; + var critical = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }).length; + var msg = pass + ' passed, ' + fail + ' failed'; + if (critical > 0) msg += ' (' + critical + ' CRITICAL)'; + UI.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success'); + } + } + + T.renderProgress = function () { + if (!T.el.progress) return; + var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length; + var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length; + var total = T.results.length; + var pct = total > 0 ? Math.round((pass / total) * 100) : 0; + + T.el.progress.innerHTML = ''; + if (total === 0) return; + + // Progress bar + var barOuter = $('div', { + style: { + height: '8px', borderRadius: '4px', background: 'var(--bg-raised)', + overflow: 'hidden', marginBottom: '8px' + } + }); + var barInner = $('div', { + style: { + height: '100%', borderRadius: '4px', + width: pct + '%', + background: fail > 0 ? 'var(--warning)' : 'var(--success)', + transition: 'width 200ms ease' + } + }); + barOuter.appendChild(barInner); + T.el.progress.appendChild(barOuter); + + var label = $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } }, + (T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail · ' + pct + '%'); + T.el.progress.appendChild(label); + } + + T.renderSummary = function () { + if (!T.el.summary) return; + T.el.summary.innerHTML = ''; + if (T.results.length === 0) return; + + // Group by domain + var domains = {}; + T.results.forEach(function (r) { + var k = r.domain; + if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 }; + domains[k].total++; + domains[k].ms += r.duration; + if (r.status === 'pass') domains[k].pass++; + else domains[k].fail++; + }); + + var grid = $('div', { + style: { + display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', + gap: '8px' + } + }); + + Object.keys(domains).forEach(function (d) { + var s = domains[d]; + var allPass = s.fail === 0; + var card = $('div', { + style: { + background: 'var(--bg-surface)', border: '1px solid var(--border)', + borderRadius: 'var(--radius)', padding: '10px 14px', + borderLeft: '3px solid ' + (allPass ? 'var(--success)' : 'var(--danger)') + } + }, [ + $('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '4px' } }, d), + $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } }, + s.pass + '/' + s.total + ' pass · ' + s.ms + 'ms') + ]); + grid.appendChild(card); + }); + + T.el.summary.appendChild(grid); + } + + T.renderDetail = function () { + if (!T.el.detail) return; + T.el.detail.innerHTML = ''; + if (T.results.length === 0) return; + + var table = $('table', { + style: { + width: '100%', borderCollapse: 'collapse', fontSize: '13px', + fontFamily: 'var(--mono)', background: 'var(--bg-surface)', + border: '1px solid var(--border)', borderRadius: 'var(--radius)' + } + }); + + // Header + var thead = $('thead'); + var trh = $('tr', { style: { borderBottom: '1px solid var(--border)' } }); + ['', 'Tier', 'Domain', 'Test', 'ms', 'Detail'].forEach(function (h) { + trh.appendChild($('th', { + style: { + textAlign: 'left', padding: '8px 10px', fontSize: '11px', + fontWeight: '600', color: 'var(--text-3)', textTransform: 'uppercase', + letterSpacing: '0.5px' + } + }, h)); + }); + thead.appendChild(trh); + table.appendChild(thead); + + var tbody = $('tbody'); + T.results.forEach(function (r) { + var isPass = r.status === 'pass'; + var tr = $('tr', { + style: { + borderBottom: '1px solid var(--border)', + background: isPass ? 'transparent' : 'rgba(239,68,68,0.04)' + } + }); + + // Status dot + tr.appendChild($('td', { style: { padding: '6px 10px', width: '20px' } }, [ + $('span', { + style: { + display: 'inline-block', width: '8px', height: '8px', + borderRadius: '50%', background: isPass ? 'var(--success)' : 'var(--danger)' + } + }) + ])); + + tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)' } }, r.tier)); + tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--accent)' } }, r.domain)); + tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text)' } }, r.name)); + tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)', textAlign: 'right' } }, String(r.duration))); + + var detailTd = $('td', { + style: { + padding: '6px 10px', color: isPass ? 'var(--text-3)' : 'var(--danger)', + maxWidth: '320px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + cursor: r.detail ? 'pointer' : 'default' + }, + title: r.detail || '' + }, r.detail ? r.detail.substring(0, 120) : '✓'); + + if (r.detail && r.detail.length > 120) { + detailTd.addEventListener('click', function () { + if (detailTd.style.whiteSpace === 'nowrap') { + detailTd.style.whiteSpace = 'pre-wrap'; + detailTd.style.wordBreak = 'break-all'; + detailTd.textContent = r.detail; + } else { + detailTd.style.whiteSpace = 'nowrap'; + detailTd.textContent = r.detail.substring(0, 120); + } + }); + } + + tr.appendChild(detailTd); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + + T.el.detail.appendChild(table); + } + +})(); diff --git a/surfaces/icd-test-runner/manifest.json b/surfaces/icd-test-runner/manifest.json new file mode 100644 index 0000000..74c1a36 --- /dev/null +++ b/surfaces/icd-test-runner/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "icd-test-runner", + "title": "ICD Test Runner", + "route": "/s/icd-test-runner", + "auth": "authenticated", + "layout": "single", + "version": "0.28.4.0", + "description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, and provider tiers." +}