Files
chenhg5-cc-connect/core/management_test.go
Yu Zhang c580b68716 feat(cron): align manual trigger command with exec (#1201)
* feat: add manual cron run support

* feat(cron): align manual trigger command with exec

* fix(cron): guard shell manual triggers

* test(cron): accept manual run output before trigger ack

* test(release): align footer expectations without markdown italics

* fix(core): keep full reply footer paths

* test(core): normalize local dir path expectation

* fix(cron): check exec response body close

---------

Co-authored-by: aoko <aokodesuka@gmail.com>
Co-authored-by: 张彧 <aaron@mac.tail449498.ts.net>
Co-authored-by: 张彧 <aaron@Aaron-MacBook-Pro-14.local>
2026-06-04 09:55:04 +08:00

2857 lines
81 KiB
Go

package core
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"sync"
"testing"
"time"
)
type deadlineAwareModelAgent struct {
stubModelModeAgent
mu sync.Mutex
hasDeadline bool
}
func (a *deadlineAwareModelAgent) AvailableModels(ctx context.Context) []ModelOption {
a.mu.Lock()
_, ok := ctx.Deadline()
a.hasDeadline = ok
a.mu.Unlock()
return []ModelOption{{Name: "gpt-4.1"}}
}
func (a *deadlineAwareModelAgent) sawDeadline() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.hasDeadline
}
// testManagementServer creates a ManagementServer with a test engine and returns an httptest.Server.
func testManagementServer(t *testing.T, token string) (*ManagementServer, *httptest.Server, *Engine) {
t.Helper()
agent := &stubAgent{}
sm := NewSessionManager("")
e := NewEngine("test-project", agent, nil, "", LangEnglish)
e.sessions = sm
mgmt := NewManagementServer(0, token, nil)
mgmt.RegisterEngine("test-project", e)
mux := http.NewServeMux()
prefix := "/api/v1"
mux.HandleFunc(prefix+"/status", mgmt.wrap(mgmt.handleStatus))
mux.HandleFunc(prefix+"/restart", mgmt.wrap(mgmt.handleRestart))
mux.HandleFunc(prefix+"/reload", mgmt.wrap(mgmt.handleReload))
mux.HandleFunc(prefix+"/config", mgmt.wrap(mgmt.handleConfig))
mux.HandleFunc(prefix+"/settings", mgmt.wrap(mgmt.handleGlobalSettings))
mux.HandleFunc(prefix+"/agents", mgmt.wrap(mgmt.handleAgents))
mux.HandleFunc(prefix+"/projects", mgmt.wrap(mgmt.handleProjects))
mux.HandleFunc(prefix+"/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
mux.HandleFunc(prefix+"/cron", mgmt.wrap(mgmt.handleCron))
mux.HandleFunc(prefix+"/cron/", mgmt.wrap(mgmt.handleCronByID))
mux.HandleFunc(prefix+"/providers", mgmt.wrap(mgmt.handleGlobalProviders))
mux.HandleFunc(prefix+"/providers/", mgmt.wrap(mgmt.handleGlobalProviderRoutes))
mux.HandleFunc(prefix+"/skills", mgmt.wrap(mgmt.handleSkills))
mux.HandleFunc(prefix+"/skills/presets", mgmt.wrap(mgmt.handleSkillPresets))
mux.HandleFunc(prefix+"/bridge/adapters", mgmt.wrap(mgmt.handleBridgeAdapters))
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return mgmt, ts, e
}
type mgmtResponse struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func mgmtGet(t *testing.T, url, token string) mgmtResponse {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode GET response: %v", err)
}
return r
}
func mgmtPost(t *testing.T, url, token string, body any) mgmtResponse {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode POST body: %v", err)
}
}
req, _ := http.NewRequest("POST", url, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode POST response: %v", err)
}
return r
}
func mgmtPatch(t *testing.T, url, token string, body any) mgmtResponse {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode PATCH body: %v", err)
}
}
req, _ := http.NewRequest("PATCH", url, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PATCH %s: %v", url, err)
}
defer resp.Body.Close()
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode PATCH response: %v", err)
}
return r
}
func mgmtDelete(t *testing.T, url, token string) mgmtResponse {
t.Helper()
req, _ := http.NewRequest("DELETE", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("DELETE %s: %v", url, err)
}
defer resp.Body.Close()
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode DELETE response: %v", err)
}
return r
}
func TestMgmt_AuthRequired(t *testing.T) {
_, ts, _ := testManagementServer(t, "secret-token")
r := mgmtGet(t, ts.URL+"/api/v1/status", "")
if r.OK {
t.Fatal("expected auth failure without token")
}
if !strings.Contains(r.Error, "unauthorized") {
t.Fatalf("expected unauthorized error, got: %s", r.Error)
}
r = mgmtGet(t, ts.URL+"/api/v1/status", "wrong-token")
if r.OK {
t.Fatal("expected auth failure with wrong token")
}
r = mgmtGet(t, ts.URL+"/api/v1/status", "secret-token")
if !r.OK {
t.Fatalf("expected success with correct token, got error: %s", r.Error)
}
}
func TestMgmt_AuthQueryParam(t *testing.T) {
_, ts, _ := testManagementServer(t, "qp-token")
r := mgmtGet(t, ts.URL+"/api/v1/status?token=qp-token", "")
if !r.OK {
t.Fatalf("expected success with query param token, got: %s", r.Error)
}
}
func TestMgmt_NoAuthRequired(t *testing.T) {
_, ts, _ := testManagementServer(t, "")
r := mgmtGet(t, ts.URL+"/api/v1/status", "")
if !r.OK {
t.Fatalf("expected success without token when no token configured, got: %s", r.Error)
}
}
func TestMgmt_Status(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/status", "tok")
if !r.OK {
t.Fatalf("status failed: %s", r.Error)
}
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal status data: %v", err)
}
if data["projects_count"] != float64(1) {
t.Fatalf("expected 1 project, got %v", data["projects_count"])
}
}
func TestMgmt_StatusIncludesBridgeToken(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", nil))
r := mgmtGet(t, ts.URL+"/api/v1/status", "tok")
if !r.OK {
t.Fatalf("status failed: %s", r.Error)
}
var data struct {
Bridge struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Path string `json:"path"`
Token string `json:"token"`
} `json:"bridge"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal status data: %v", err)
}
if !data.Bridge.Enabled {
t.Fatal("expected bridge to be enabled")
}
if data.Bridge.Token != "bridge-secret" {
t.Fatalf("expected bridge token, got %q", data.Bridge.Token)
}
}
func TestMgmt_Projects(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects", "tok")
if !r.OK {
t.Fatalf("projects failed: %s", r.Error)
}
var data struct {
Projects []map[string]any `json:"projects"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal projects data: %v", err)
}
if len(data.Projects) != 1 {
t.Fatalf("expected 1 project, got %d", len(data.Projects))
}
if data.Projects[0]["name"] != "test-project" {
t.Fatalf("expected test-project, got %v", data.Projects[0]["name"])
}
}
func TestMgmt_ProjectDetail(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project", "tok")
if !r.OK {
t.Fatalf("project detail failed: %s", r.Error)
}
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal project detail: %v", err)
}
if data["name"] != "test-project" {
t.Fatalf("expected test-project, got %v", data["name"])
}
r = mgmtGet(t, ts.URL+"/api/v1/projects/nonexistent", "tok")
if r.OK {
t.Fatal("expected 404 for nonexistent project")
}
}
func TestMgmt_ProjectPatch(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project", "tok", map[string]any{
"language": "zh",
})
if !r.OK {
t.Fatalf("patch failed: %s", r.Error)
}
}
func TestMgmt_Sessions(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
e.sessions.GetOrCreateActive("user1")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/sessions", "tok")
if !r.OK {
t.Fatalf("sessions list failed: %s", r.Error)
}
// Create a session via API
r = mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions", "tok", map[string]string{
"session_key": "user2",
"name": "work",
})
if !r.OK {
t.Fatalf("create session failed: %s", r.Error)
}
}
func TestMgmt_SessionDetail(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
s := e.sessions.GetOrCreateActive("user1")
s.AddHistory("user", "hello")
s.AddHistory("assistant", "hi there")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/sessions/"+s.ID, "tok")
if !r.OK {
t.Fatalf("session detail failed: %s", r.Error)
}
var data struct {
History []map[string]any `json:"history"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal session detail: %v", err)
}
if len(data.History) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(data.History))
}
}
// TestMgmt_SessionsConcurrentNameWriteAndList pins the bug where the
// POST /sessions handler wrote s.Name = body.Name directly without
// holding s.mu, while the GET handler reads s.Name through s.mu.Lock().
// Run with -race to detect the data race; with the production fix
// the test stays clean.
func TestMgmt_SessionsConcurrentNameWriteAndList(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
// Pre-create the session so both handlers operate on the same instance.
e.sessions.GetOrCreateActive("user1")
listURL := ts.URL + "/api/v1/projects/test-project/sessions"
postURL := listURL
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
name := "a"
if i%2 == 0 {
name = "b"
}
_ = mgmtPost(t, postURL, "tok", map[string]string{
"session_key": "user1",
"name": name,
})
}(i)
}
for i := 0; i < 30; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = mgmtGet(t, listURL, "tok")
}()
}
wg.Wait()
}
func TestMgmt_SessionDelete(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
s := e.sessions.GetOrCreateActive("user1")
sid := s.ID
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project/sessions/"+sid, "tok")
if !r.OK {
t.Fatalf("delete session failed: %s", r.Error)
}
r = mgmtGet(t, ts.URL+"/api/v1/projects/test-project/sessions/"+sid, "tok")
if r.OK {
t.Fatal("expected 404 after deletion")
}
}
func TestMgmt_Config(t *testing.T) {
srv, ts, _ := testManagementServer(t, "tok")
// Write a temp TOML file and point the server at it
tmp := t.TempDir()
cfgPath := tmp + "/config.toml"
if err := os.WriteFile(cfgPath, []byte("[display]\ntitle = \"test\"\n"), 0644); err != nil {
t.Fatal(err)
}
srv.SetConfigFilePath(cfgPath)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/config", nil)
req.Header.Set("Authorization", "Bearer tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "title") {
t.Fatalf("expected TOML content, got: %s", body)
}
}
func TestMgmt_Reload(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
reloaded := false
e.configReloadFunc = func() (*ConfigReloadResult, error) {
reloaded = true
return &ConfigReloadResult{}, nil
}
r := mgmtPost(t, ts.URL+"/api/v1/reload", "tok", nil)
if !r.OK {
t.Fatalf("reload failed: %s", r.Error)
}
if !reloaded {
t.Fatal("expected config reload to be triggered")
}
}
func TestMgmt_BridgeAdapters(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/bridge/adapters", "tok")
if !r.OK {
t.Fatalf("bridge adapters failed: %s", r.Error)
}
}
func TestMgmt_HeartbeatNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/heartbeat", "tok")
if r.OK {
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal heartbeat data: %v", err)
}
// heartbeat scheduler is nil, so we expect service unavailable
}
}
func TestMgmt_HeartbeatWithScheduler(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/heartbeat", "tok")
if !r.OK {
t.Fatalf("heartbeat status failed: %s", r.Error)
}
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal heartbeat status: %v", err)
}
if data["enabled"] != false {
t.Fatalf("expected heartbeat disabled, got %v", data["enabled"])
}
}
func TestMgmt_CronNilScheduler(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/cron", "tok")
if r.OK {
t.Fatal("expected error when cron scheduler is nil")
}
}
func TestMgmt_CronWithScheduler(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
// List (empty)
r := mgmtGet(t, ts.URL+"/api/v1/cron", "tok")
if !r.OK {
t.Fatalf("cron list failed: %s", r.Error)
}
// Add
r = mgmtPost(t, ts.URL+"/api/v1/cron", "tok", map[string]any{
"project": "test-project",
"session_key": "user1",
"cron_expr": "0 9 * * *",
"prompt": "hello",
"description": "test cron",
})
if !r.OK {
t.Fatalf("cron add failed: %s", r.Error)
}
var job CronJob
if err := json.Unmarshal(r.Data, &job); err != nil {
t.Fatalf("unmarshal cron job: %v", err)
}
if job.ID == "" {
t.Fatal("expected cron job ID")
}
// List (should have 1)
r = mgmtGet(t, ts.URL+"/api/v1/cron", "tok")
if !r.OK {
t.Fatalf("cron list failed: %s", r.Error)
}
// Delete
r = mgmtDelete(t, ts.URL+"/api/v1/cron/"+job.ID, "tok")
if !r.OK {
t.Fatalf("cron delete failed: %s", r.Error)
}
// Delete nonexistent
r = mgmtDelete(t, ts.URL+"/api/v1/cron/nonexistent", "tok")
if r.OK {
t.Fatal("expected 404 for nonexistent cron job")
}
}
func TestMgmt_CronExecByID(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
mgmt.SetCronScheduler(cs)
platform := &stubCronReplyTargetPlatform{
stubPlatformEngine: stubPlatformEngine{n: "discord"},
}
agentSession := newResultAgentSession("triggered from management")
e.platforms = []Platform{platform}
e.agent = &resultAgent{session: agentSession}
e.cronScheduler = cs
cs.RegisterEngine("test-project", e)
job := &CronJob{
ID: "cron-run-1",
Project: "test-project",
SessionKey: "discord:channel-1:user-1",
CronExpr: "0 9 * * *",
Prompt: "hello",
Description: "Run me now",
Enabled: false,
CreatedAt: time.Now(),
}
if err := store.Add(job); err != nil {
t.Fatal(err)
}
r := mgmtPost(t, ts.URL+"/api/v1/cron/"+job.ID+"/exec", "tok", map[string]any{})
if !r.OK {
t.Fatalf("cron exec failed: %s", r.Error)
}
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal run response: %v", err)
}
if data["status"] != "triggered" {
t.Fatalf("status = %v, want triggered", data["status"])
}
if data["id"] != job.ID {
t.Fatalf("id = %v, want %s", data["id"], job.ID)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(platform.getSent()) >= 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(platform.getSent()) < 2 {
t.Fatalf("timed out waiting for triggered cron exec, sent=%v", platform.getSent())
}
aliasJob := &CronJob{
ID: "cron-run-alias-1",
Project: "test-project",
SessionKey: "discord:channel-2:user-2",
CronExpr: "0 9 * * *",
Prompt: "hello alias",
Description: "Run alias now",
Enabled: false,
CreatedAt: time.Now(),
}
if err := store.Add(aliasJob); err != nil {
t.Fatal(err)
}
e.agent = &resultAgent{session: newResultAgentSession("triggered from management alias")}
alias := mgmtPost(t, ts.URL+"/api/v1/cron/"+aliasJob.ID+"/run", "tok", map[string]any{})
if !alias.OK {
t.Fatalf("cron run compatibility alias failed: %s", alias.Error)
}
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(platform.getSent()) >= 4 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for triggered cron run alias, sent=%v", platform.getSent())
}
func TestMgmt_CronExecByID_RejectsExtraPathSegments(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
mgmt.SetCronScheduler(cs)
cs.RegisterEngine("test-project", e)
job := &CronJob{
ID: "cron-run-extra",
Project: "test-project",
SessionKey: "discord:channel-1:user-1",
CronExpr: "0 9 * * *",
Prompt: "hello",
Enabled: true,
CreatedAt: time.Now(),
}
if err := store.Add(job); err != nil {
t.Fatal(err)
}
r := mgmtPost(t, ts.URL+"/api/v1/cron/"+job.ID+"/exec/extra", "tok", map[string]any{})
if r.OK {
t.Fatal("expected extra cron path segment to be rejected")
}
if !strings.Contains(r.Error, "unknown cron route") {
t.Fatalf("error = %q, want unknown cron route", r.Error)
}
}
func TestMgmt_CronExecByID_ProjectMissingIsBadRequest(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
mgmt.SetCronScheduler(cs)
cs.RegisterEngine("test-project", e)
job := &CronJob{
ID: "cron-run-missing-project",
Project: "ghost",
SessionKey: "discord:channel-1:user-1",
CronExpr: "0 9 * * *",
Prompt: "hello",
Enabled: true,
CreatedAt: time.Now(),
}
if err := store.Add(job); err != nil {
t.Fatal(err)
}
r := mgmtPost(t, ts.URL+"/api/v1/cron/"+job.ID+"/exec", "tok", map[string]any{})
if r.OK {
t.Fatal("expected exec to fail when project is missing")
}
if !strings.Contains(r.Error, "project") {
t.Fatalf("error = %q, want project error", r.Error)
}
if !strings.Contains(r.Error, "not found") {
t.Fatalf("error = %q, want missing project details", r.Error)
}
}
func TestMgmt_CORS(t *testing.T) {
mgmt := NewManagementServer(0, "", []string{"http://localhost:3000"})
mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish))
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/status", mgmt.wrap(mgmt.handleStatus))
ts := httptest.NewServer(mux)
defer ts.Close()
req, _ := http.NewRequest("OPTIONS", ts.URL+"/api/v1/status", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204 for OPTIONS, got %d", resp.StatusCode)
}
if resp.Header.Get("Access-Control-Allow-Origin") != "http://localhost:3000" {
t.Fatalf("expected CORS origin header, got %q", resp.Header.Get("Access-Control-Allow-Origin"))
}
}
func TestMgmt_BridgeWebSocketPathProxiesToBridgeServer(t *testing.T) {
mgmt := NewManagementServer(0, "", []string{"*"})
mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish))
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"}))
mux := http.NewServeMux()
ts := httptest.NewServer(mgmt.buildHandler(mux))
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected websocket upgrade, got %d", resp.StatusCode)
}
}
func TestMgmt_BridgeWebSocketPathWorksWhenBridgeServerSetAfterHandlerBuild(t *testing.T) {
mgmt := NewManagementServer(0, "", []string{"*"})
mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish))
mux := http.NewServeMux()
ts := httptest.NewServer(mgmt.buildHandler(mux))
defer ts.Close()
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"}))
req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected websocket upgrade after late bridge setup, got %d", resp.StatusCode)
}
}
func TestMgmt_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/status", nil)
req.Header.Set("Authorization", "Bearer tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode method not allowed response: %v", err)
}
resp.Body.Close()
}
func TestMgmt_ProjectModel_UsesSwitchModelWithActiveProvider(t *testing.T) {
agent := &stubModelModeAgent{
model: "gpt-4.1-mini",
providers: []ProviderConfig{
{
Name: "openai",
Model: "gpt-4.1-mini",
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
},
},
active: "openai",
}
e := NewEngine("test-project", agent, nil, "", LangEnglish)
var savedProvider, savedModel string
e.SetProviderModelSaveFunc(func(providerName, model string) error {
savedProvider = providerName
savedModel = model
return nil
})
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("test-project", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/model", "tok", map[string]string{"model": "gpt-4.1"})
if !r.OK {
t.Fatalf("update model failed: %s", r.Error)
}
if got := agent.GetModel(); got != "gpt-4.1" {
t.Fatalf("GetModel() = %q, want gpt-4.1", got)
}
if got := agent.GetActiveProvider(); got == nil || got.Model != "gpt-4.1" {
t.Fatalf("active provider model = %#v, want gpt-4.1", got)
}
if savedProvider != "openai" || savedModel != "gpt-4.1" {
t.Fatalf("saved provider/model = %q/%q, want openai/gpt-4.1", savedProvider, savedModel)
}
}
func TestMgmt_ProjectModel_SavesModelWithoutActiveProvider(t *testing.T) {
agent := &stubModelModeAgent{
model: "gpt-4.1-mini",
providers: []ProviderConfig{
{
Name: "openai",
Model: "gpt-4.1-mini",
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
},
},
}
e := NewEngine("test-project", agent, nil, "", LangEnglish)
var savedModel string
var providerSaveCalled bool
e.SetModelSaveFunc(func(model string) error {
savedModel = model
return nil
})
e.SetProviderModelSaveFunc(func(providerName, model string) error {
providerSaveCalled = true
return nil
})
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("test-project", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/model", "tok", map[string]string{"model": "gpt-4.1"})
if !r.OK {
t.Fatalf("update model failed: %s", r.Error)
}
if got := agent.GetModel(); got != "gpt-4.1" {
t.Fatalf("GetModel() = %q, want gpt-4.1", got)
}
if savedModel != "gpt-4.1" {
t.Fatalf("saved model = %q, want gpt-4.1", savedModel)
}
if providerSaveCalled {
t.Fatal("provider save callback should not be called without active provider")
}
}
func TestMgmt_ProjectModel_ReturnsErrorWhenModelSaveFails(t *testing.T) {
agent := &stubModelModeAgent{
model: "gpt-4.1-mini",
providers: []ProviderConfig{
{
Name: "openai",
Model: "gpt-4.1-mini",
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
},
},
}
e := NewEngine("test-project", agent, nil, "", LangEnglish)
e.SetModelSaveFunc(func(model string) error {
return errors.New("disk full")
})
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("test-project", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/model", "tok", map[string]string{"model": "gpt-4.1"})
if r.OK {
t.Fatal("update model unexpectedly succeeded")
}
if !strings.Contains(r.Error, "disk full") {
t.Fatalf("error = %q, want save failure", r.Error)
}
if got := agent.GetModel(); got != "gpt-4.1-mini" {
t.Fatalf("GetModel() = %q, want unchanged gpt-4.1-mini", got)
}
}
func TestMgmt_ProjectModels_UsesTimeoutContext(t *testing.T) {
agent := &deadlineAwareModelAgent{}
e := NewEngine("test-project", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("test-project", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/models", "tok")
if !r.OK {
t.Fatalf("project models failed: %s", r.Error)
}
if !agent.sawDeadline() {
t.Fatal("AvailableModels context has no deadline; want timeout-bounded context")
}
}
func TestMgmt_RemoveGlobalProvider_PurgesFromEngines(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{
{Name: "prov-a", BaseURL: "https://a.example"},
{Name: "prov-b", BaseURL: "https://b.example"},
},
active: "prov-b",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
removed := ""
mgmt.SetRemoveGlobalProvider(func(name string) error {
removed = name
return nil
})
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/providers/", mgmt.wrap(mgmt.handleGlobalProviderRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
req, _ := http.NewRequest("DELETE", ts.URL+"/api/v1/providers/prov-a", nil)
req.Header.Set("Authorization", "Bearer tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("DELETE: %v", err)
}
resp.Body.Close()
if removed != "prov-a" {
t.Fatalf("removeGlobalProvider called with %q, want prov-a", removed)
}
remaining := agent.ListProviders()
if len(remaining) != 1 {
t.Fatalf("remaining providers = %d, want 1", len(remaining))
}
if remaining[0].Name != "prov-b" {
t.Fatalf("remaining provider = %q, want prov-b", remaining[0].Name)
}
}
func TestResolveGlobalProviderForAgent(t *testing.T) {
g := GlobalProviderInfo{
Name: "relay",
APIKey: "sk-123",
BaseURL: "https://api.example.com/anthropic",
Model: "claude-sonnet-4",
Endpoints: map[string]string{
"codex": "https://api.example.com/v1",
},
AgentModels: map[string]string{
"codex": "gpt-5.3-codex",
},
AgentModelLists: map[string][]GlobalModelEntry{
"codex": {{Model: "gpt-5.3-codex"}, {Model: "gpt-5.4"}},
},
Models: []struct {
Model string `json:"model"`
Alias string `json:"alias,omitempty"`
}{{Model: "claude-sonnet-4"}, {Model: "claude-opus-4"}},
}
// claudecode: should use top-level values
cc := resolveGlobalProviderForAgent(g, "claudecode")
if cc.BaseURL != "https://api.example.com/anthropic" {
t.Errorf("claudecode BaseURL = %q", cc.BaseURL)
}
if cc.Model != "claude-sonnet-4" {
t.Errorf("claudecode Model = %q", cc.Model)
}
if len(cc.Models) != 2 || cc.Models[0].Name != "claude-sonnet-4" {
t.Errorf("claudecode Models = %v", cc.Models)
}
// codex: should use per-agent overrides
cx := resolveGlobalProviderForAgent(g, "codex")
if cx.BaseURL != "https://api.example.com/v1" {
t.Errorf("codex BaseURL = %q", cx.BaseURL)
}
if cx.Model != "gpt-5.3-codex" {
t.Errorf("codex Model = %q", cx.Model)
}
if len(cx.Models) != 2 || cx.Models[0].Name != "gpt-5.3-codex" {
t.Errorf("codex Models = %v", cx.Models)
}
}
func TestMgmt_AddPlatformToNewProject_DoesNotRequireEngine(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
var savedProject, savedPlatType string
mgmt.SetAddPlatformToProject(func(proj, platType string, opts map[string]any, workDir, agentType string) error {
savedProject = proj
savedPlatType = platType
return nil
})
// "brand-new-project" has no engine registered — this must NOT return 404.
r := mgmtPost(t, ts.URL+"/api/v1/projects/brand-new-project/add-platform", "tok", map[string]any{
"type": "dingtalk",
"options": map[string]any{"client_id": "abc", "client_secret": "def"},
})
if !r.OK {
t.Fatalf("add-platform to new project failed: %s — should not require a running engine", r.Error)
}
if savedProject != "brand-new-project" {
t.Fatalf("saved project = %q, want brand-new-project", savedProject)
}
if savedPlatType != "dingtalk" {
t.Fatalf("saved platform type = %q, want dingtalk", savedPlatType)
}
}
func TestMgmt_OtherRoutesStillRequireEngine(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/nonexistent/sessions", "tok")
if r.OK {
t.Fatal("expected 404 for sessions on nonexistent project")
}
if !strings.Contains(r.Error, "project not found") {
t.Fatalf("error = %q, want project not found", r.Error)
}
}
func mgmtPut(t *testing.T, url, token string, body any) mgmtResponse {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode PUT body: %v", err)
}
}
req, _ := http.NewRequest("PUT", url, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT %s: %v", url, err)
}
defer resp.Body.Close()
var r mgmtResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return r
}
// ── Restart ──
func TestMgmt_Restart(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/restart", "tok", nil)
if !r.OK {
t.Fatalf("restart failed: %s", r.Error)
}
select {
case req := <-RestartCh:
_ = req
default:
}
}
func TestMgmt_Restart_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/restart", "tok")
if r.OK {
t.Fatal("expected GET on restart to fail")
}
}
// ── Agents ──
func TestMgmt_Agents(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/agents", "tok")
if !r.OK {
t.Fatalf("agents failed: %s", r.Error)
}
var data struct {
Agents []string `json:"agents"`
Platforms []string `json:"platforms"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal agents: %v", err)
}
}
func TestMgmt_Agents_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/agents", "tok", nil)
if r.OK {
t.Fatal("expected POST on agents to fail")
}
}
// ── Global Settings ──
func TestMgmt_GlobalSettings_Get(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetGetGlobalSettings(func() map[string]any {
return map[string]any{"language": "zh", "auto_start": true}
})
r := mgmtGet(t, ts.URL+"/api/v1/settings", "tok")
if !r.OK {
t.Fatalf("get settings failed: %s", r.Error)
}
var data map[string]any
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if data["language"] != "zh" {
t.Fatalf("language = %v, want zh", data["language"])
}
}
func TestMgmt_GlobalSettings_GetNotAvailable(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/settings", "tok")
if r.OK {
t.Fatal("expected error when getGlobalSettings is nil")
}
}
func TestMgmt_GlobalSettings_Patch(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
saved := map[string]any{}
mgmt.SetGetGlobalSettings(func() map[string]any { return saved })
mgmt.SetSaveGlobalSettings(func(updates map[string]any) error {
for k, v := range updates {
saved[k] = v
}
return nil
})
r := mgmtPatch(t, ts.URL+"/api/v1/settings", "tok", map[string]any{"language": "ja"})
if !r.OK {
t.Fatalf("patch settings failed: %s", r.Error)
}
if saved["language"] != "ja" {
t.Fatalf("saved language = %v, want ja", saved["language"])
}
}
func TestMgmt_GlobalSettings_PatchSaveError(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetSaveGlobalSettings(func(updates map[string]any) error {
return errors.New("write failed")
})
r := mgmtPatch(t, ts.URL+"/api/v1/settings", "tok", map[string]any{"x": 1})
if r.OK {
t.Fatal("expected save error")
}
if !strings.Contains(r.Error, "write failed") {
t.Fatalf("error = %q, want write failed", r.Error)
}
}
// ── Project Send ──
func TestMgmt_ProjectSend_EmptyMessage(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/send", "tok", map[string]string{
"session_key": "user1",
"message": "",
})
if r.OK {
t.Fatal("expected error for empty message")
}
if !strings.Contains(r.Error, "message is required") {
t.Fatalf("error = %q, want message required", r.Error)
}
}
func TestMgmt_ProjectSend_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/send", "tok")
if r.OK {
t.Fatal("expected GET on send to fail")
}
}
// ── Project Providers ──
func TestMgmt_ProjectProviders_NoProviderSwitcher(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/providers", "tok")
if r.OK {
t.Fatal("expected error when agent doesn't support ProviderSwitcher")
}
if !strings.Contains(r.Error, "provider switching") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_ProjectProviders_ListAndAdd(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{{Name: "openai", BaseURL: "https://api.openai.com"}},
active: "openai",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
// GET list
r := mgmtGet(t, ts.URL+"/api/v1/projects/proj/providers", "tok")
if !r.OK {
t.Fatalf("list providers failed: %s", r.Error)
}
var list struct {
Providers []map[string]any `json:"providers"`
ActiveProvider string `json:"active_provider"`
}
if err := json.Unmarshal(r.Data, &list); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(list.Providers) != 1 {
t.Fatalf("providers count = %d, want 1", len(list.Providers))
}
if list.ActiveProvider != "openai" {
t.Fatalf("active = %q, want openai", list.ActiveProvider)
}
// POST add
r = mgmtPost(t, ts.URL+"/api/v1/projects/proj/providers", "tok", map[string]string{
"name": "anthropic",
"api_key": "sk-test",
"base_url": "https://api.anthropic.com",
})
if !r.OK {
t.Fatalf("add provider failed: %s", r.Error)
}
if len(agent.providers) != 2 {
t.Fatalf("providers count = %d, want 2", len(agent.providers))
}
}
func TestMgmt_ProjectProviders_AddMissingName(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{{Name: "openai"}},
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/proj/providers", "tok", map[string]string{"api_key": "sk"})
if r.OK {
t.Fatal("expected error for missing name")
}
}
func TestMgmt_ProjectProviders_Activate(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{
{Name: "openai", BaseURL: "https://openai.com"},
{Name: "claude", BaseURL: "https://claude.ai"},
},
active: "openai",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/proj/providers/claude/activate", "tok", nil)
if !r.OK {
t.Fatalf("activate failed: %s", r.Error)
}
if agent.active != "claude" {
t.Fatalf("active = %q, want claude", agent.active)
}
}
func TestMgmt_ProjectProviders_ActivateNotFound(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{{Name: "openai"}},
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPost(t, ts.URL+"/api/v1/projects/proj/providers/nope/activate", "tok", nil)
if r.OK {
t.Fatal("expected 404 for nonexistent provider")
}
}
func TestMgmt_ProjectProviders_DeleteActive(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{
{Name: "openai"},
{Name: "claude"},
},
active: "openai",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtDelete(t, ts.URL+"/api/v1/projects/proj/providers/openai", "tok")
if r.OK {
t.Fatal("expected error when deleting active provider")
}
if !strings.Contains(r.Error, "active provider") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_ProjectProviders_DeleteInactive(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{
{Name: "openai"},
{Name: "claude"},
},
active: "openai",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtDelete(t, ts.URL+"/api/v1/projects/proj/providers/claude", "tok")
if !r.OK {
t.Fatalf("delete failed: %s", r.Error)
}
if len(agent.providers) != 1 {
t.Fatalf("providers = %d, want 1", len(agent.providers))
}
}
// ── Project Provider Refs ──
func TestMgmt_ProjectProviderRefs_GetEmpty(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "openai"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtGet(t, ts.URL+"/api/v1/projects/proj/provider-refs", "tok")
if !r.OK {
t.Fatalf("get provider-refs failed: %s", r.Error)
}
var data struct {
ProviderRefs []string `json:"provider_refs"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(data.ProviderRefs) != 0 {
t.Fatalf("expected empty refs, got %v", data.ProviderRefs)
}
}
func TestMgmt_ProjectProviderRefs_PutNotConfigured(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "openai"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPut(t, ts.URL+"/api/v1/projects/proj/provider-refs", "tok", map[string]any{
"provider_refs": []string{"shared-1"},
})
if r.OK {
t.Fatal("expected error when saveProviderRefs is nil")
}
}
// ── Project Users ──
func TestMgmt_ProjectUsers_Get(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/users", "tok")
if !r.OK {
t.Fatalf("get users failed: %s", r.Error)
}
}
func TestMgmt_ProjectUsers_PatchInvalidJSON(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("PATCH", ts.URL+"/api/v1/projects/test-project/users", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
// ── Project Delete ──
func TestMgmt_ProjectDelete_NotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project", "tok")
if r.OK {
t.Fatal("expected error when removeProject is nil")
}
if !strings.Contains(r.Error, "not configured") {
t.Fatalf("error = %q", r.Error)
}
}
// ── Global Providers ──
func TestMgmt_GlobalProviders_GetEmpty(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/providers", "tok")
if !r.OK {
t.Fatalf("get global providers failed: %s", r.Error)
}
var data struct {
Providers []any `json:"providers"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(data.Providers) != 0 {
t.Fatalf("expected empty, got %d", len(data.Providers))
}
}
func TestMgmt_GlobalProviders_GetWithFunc(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetListGlobalProviders(func() ([]GlobalProviderInfo, error) {
return []GlobalProviderInfo{{Name: "shared-relay"}}, nil
})
r := mgmtGet(t, ts.URL+"/api/v1/providers", "tok")
if !r.OK {
t.Fatalf("get providers failed: %s", r.Error)
}
}
func TestMgmt_GlobalProviders_PostNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/providers", "tok", map[string]string{"name": "new"})
if r.OK {
t.Fatal("expected error when addGlobalProvider is nil")
}
}
func TestMgmt_GlobalProviders_PostMissingName(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetAddGlobalProvider(func(info GlobalProviderInfo) error { return nil })
r := mgmtPost(t, ts.URL+"/api/v1/providers", "tok", map[string]string{"api_key": "sk"})
if r.OK {
t.Fatal("expected error for missing name")
}
}
func TestMgmt_GlobalProviders_PostSuccess(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
var added string
mgmt.SetAddGlobalProvider(func(info GlobalProviderInfo) error {
added = info.Name
return nil
})
r := mgmtPost(t, ts.URL+"/api/v1/providers", "tok", map[string]string{"name": "new-relay"})
if !r.OK {
t.Fatalf("add global provider failed: %s", r.Error)
}
if added != "new-relay" {
t.Fatalf("added = %q, want new-relay", added)
}
}
func TestMgmt_GlobalProviders_PostDuplicate(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetAddGlobalProvider(func(info GlobalProviderInfo) error {
return errors.New("already exists: " + info.Name)
})
r := mgmtPost(t, ts.URL+"/api/v1/providers", "tok", map[string]string{"name": "dup"})
if r.OK {
t.Fatal("expected conflict error")
}
}
func TestMgmt_GlobalProviders_UpdateNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPut(t, ts.URL+"/api/v1/providers/some-provider", "tok", map[string]string{"name": "some-provider"})
if r.OK {
t.Fatal("expected error when updateGlobalProvider is nil")
}
}
func TestMgmt_GlobalProviders_UpdateSuccess(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
var updated string
mgmt.SetUpdateGlobalProvider(func(name string, info GlobalProviderInfo) error {
updated = name
return nil
})
r := mgmtPut(t, ts.URL+"/api/v1/providers/relay-1", "tok", map[string]string{"model": "gpt-5"})
if !r.OK {
t.Fatalf("update global provider failed: %s", r.Error)
}
if updated != "relay-1" {
t.Fatalf("updated = %q, want relay-1", updated)
}
}
func TestMgmt_GlobalProviders_DeleteNotFound(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetRemoveGlobalProvider(func(name string) error {
return errors.New("not found: " + name)
})
r := mgmtDelete(t, ts.URL+"/api/v1/providers/nope", "tok")
if r.OK {
t.Fatal("expected 404")
}
}
// ── Provider Presets ──
func TestMgmt_ProviderPresets_NilFunc(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/providers/presets", "tok")
if !r.OK {
t.Fatalf("presets failed: %s", r.Error)
}
}
func TestMgmt_ProviderPresets_WithFunc(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetFetchPresets(func() (*ProviderPresetsResponse, error) {
return &ProviderPresetsResponse{Version: 2}, nil
})
r := mgmtGet(t, ts.URL+"/api/v1/providers/presets", "tok")
if !r.OK {
t.Fatalf("presets failed: %s", r.Error)
}
}
func TestMgmt_ProviderPresets_Error(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetFetchPresets(func() (*ProviderPresetsResponse, error) {
return nil, errors.New("network error")
})
r := mgmtGet(t, ts.URL+"/api/v1/providers/presets", "tok")
if r.OK {
t.Fatal("expected error")
}
}
// ── Skills ──
func TestMgmt_Skills(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/skills", "tok")
if !r.OK {
t.Fatalf("skills failed: %s", r.Error)
}
var data struct {
Projects []projectSkills `json:"projects"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(data.Projects) != 1 {
t.Fatalf("projects = %d, want 1", len(data.Projects))
}
}
func TestMgmt_Skills_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/skills", "tok", nil)
if r.OK {
t.Fatal("expected POST on skills to fail")
}
}
func TestMgmt_SkillPresets_NilFunc(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/skills/presets", "tok")
if !r.OK {
t.Fatalf("skill presets failed: %s", r.Error)
}
}
func TestMgmt_SkillPresets_WithFunc(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetFetchSkillPresets(func() (*SkillPresetsResponse, error) {
return &SkillPresetsResponse{Version: 1}, nil
})
r := mgmtGet(t, ts.URL+"/api/v1/skills/presets", "tok")
if !r.OK {
t.Fatalf("skill presets failed: %s", r.Error)
}
}
func TestMgmt_SkillPresets_Error(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetFetchSkillPresets(func() (*SkillPresetsResponse, error) {
return nil, errors.New("fetch failed")
})
r := mgmtGet(t, ts.URL+"/api/v1/skills/presets", "tok")
if r.OK {
t.Fatal("expected error")
}
}
// ── Cron PATCH (update job) ──
func TestMgmt_CronPatch(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
// Add a job
r := mgmtPost(t, ts.URL+"/api/v1/cron", "tok", map[string]any{
"project": "test-project",
"session_key": "test:chan:user",
"cron_expr": "0 9 * * *",
"prompt": "hello",
})
if !r.OK {
t.Fatalf("cron add failed: %s", r.Error)
}
var job CronJob
json.Unmarshal(r.Data, &job)
// Patch it
r = mgmtPatch(t, ts.URL+"/api/v1/cron/"+job.ID, "tok", map[string]any{
"enabled": false,
})
if !r.OK {
t.Fatalf("cron patch failed: %s", r.Error)
}
var updated CronJob
json.Unmarshal(r.Data, &updated)
if updated.Enabled {
t.Fatal("expected enabled=false after patch")
}
}
func TestMgmt_CronPatch_NonexistentJob(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtPatch(t, ts.URL+"/api/v1/cron/nonexistent", "tok", map[string]any{"enabled": false})
if r.OK {
t.Fatal("expected error for nonexistent cron job")
}
}
// ── Project routes: unknown sub-path ──
func TestMgmt_ProjectRoutes_UnknownSubpath(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/unknown-route", "tok")
if r.OK {
t.Fatal("expected 404 for unknown sub-path")
}
}
// ── Session create missing session_key ──
func TestMgmt_SessionCreate_MissingKey(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions", "tok", map[string]string{
"name": "work",
})
if r.OK {
t.Fatal("expected error for missing session_key")
}
}
// ── Reload failure ──
func TestMgmt_Reload_Failure(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
e.configReloadFunc = func() (*ConfigReloadResult, error) {
return nil, errors.New("parse error")
}
r := mgmtPost(t, ts.URL+"/api/v1/reload", "tok", nil)
if r.OK {
t.Fatal("expected reload failure")
}
if !strings.Contains(r.Error, "parse error") {
t.Fatalf("error = %q, want parse error", r.Error)
}
}
// ── Config PUT (save) ──
func TestMgmt_Config_Save(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
tmp := t.TempDir()
cfgPath := tmp + "/config.toml"
os.WriteFile(cfgPath, []byte("[display]\ntitle = \"old\"\n"), 0644)
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/config", strings.NewReader("# new config\n"))
req.Header.Set("Authorization", "Bearer tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// Without SetConfigFilePath, save should fail
if resp.StatusCode == 200 {
t.Fatal("expected error without config file path set")
}
}
// ── CC-Switch providers ──
func TestMgmt_CCSwitchProviders_NotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/providers/cc-switch", "tok")
if !r.OK {
t.Fatalf("cc-switch get failed: %s", r.Error)
}
var data map[string]any
json.Unmarshal(r.Data, &data)
if data["available"] != false {
t.Fatalf("expected available=false, got %v", data["available"])
}
}
// ────────────────────────────────────────────────────────────────
// Edge cases & boundary tests below
// ────────────────────────────────────────────────────────────────
// ── Restart edge cases ──
func TestMgmt_Restart_AlreadyInProgress(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
// Fill the buffered channel (cap=1) so the next restart is rejected.
RestartCh <- RestartRequest{}
r := mgmtPost(t, ts.URL+"/api/v1/restart", "tok", nil)
if r.OK {
t.Fatal("expected conflict when restart channel is full")
}
if !strings.Contains(r.Error, "already in progress") {
t.Fatalf("error = %q, want 'already in progress'", r.Error)
}
// Drain so other tests aren't affected.
<-RestartCh
}
func TestMgmt_Restart_WithSessionKey(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/restart", "tok", map[string]string{
"session_key": "user1",
"platform": "feishu",
})
if !r.OK {
t.Fatalf("restart with session_key failed: %s", r.Error)
}
req := <-RestartCh
if req.SessionKey != "user1" || req.Platform != "feishu" {
t.Fatalf("restart request = %+v, want session_key=user1 platform=feishu", req)
}
}
// ── Config edge cases ──
func TestMgmt_Config_NoPathSet(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/config", "tok")
if r.OK {
t.Fatal("expected error when configFilePath is empty")
}
if !strings.Contains(r.Error, "not set") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_Config_FileNotFound(t *testing.T) {
srv, ts, _ := testManagementServer(t, "tok")
srv.SetConfigFilePath("/nonexistent/path/config.toml")
r := mgmtGet(t, ts.URL+"/api/v1/config", "tok")
if r.OK {
t.Fatal("expected error for missing config file")
}
}
func TestMgmt_Config_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/config", "tok", nil)
if r.OK {
t.Fatal("expected POST on config to fail")
}
}
// ── Settings edge cases ──
func TestMgmt_GlobalSettings_PatchInvalidJSON(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetSaveGlobalSettings(func(updates map[string]any) error { return nil })
req, _ := http.NewRequest("PATCH", ts.URL+"/api/v1/settings", strings.NewReader("{bad json"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON body")
}
if !strings.Contains(r.Error, "invalid JSON") {
t.Fatalf("error = %q, want 'invalid JSON'", r.Error)
}
}
func TestMgmt_GlobalSettings_PatchNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/settings", "tok", map[string]any{"x": 1})
if r.OK {
t.Fatal("expected error when saveGlobalSettings is nil")
}
}
func TestMgmt_GlobalSettings_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/settings", "tok")
if r.OK {
t.Fatal("expected DELETE on settings to fail")
}
}
// ── Send edge cases ──
func TestMgmt_ProjectSend_InvalidJSON(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/projects/test-project/send", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_ProjectSend_NonexistentProject(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/projects/nonexistent/send", "tok", map[string]string{
"message": "hello",
})
if r.OK {
t.Fatal("expected 404 for send to nonexistent project")
}
}
// ── Project Detail PATCH edge cases ──
func TestMgmt_ProjectPatch_InvalidJSON(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("PATCH", ts.URL+"/api/v1/projects/test-project", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON body")
}
}
func TestMgmt_ProjectPatch_UnknownAgentType(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
agentType := "totally-unknown-agent"
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project", "tok", map[string]any{
"agent_type": agentType,
})
if r.OK {
t.Fatal("expected error for unknown agent type")
}
if !strings.Contains(r.Error, "unknown agent type") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_ProjectPatch_DisabledCommands(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project", "tok", map[string]any{
"disabled_commands": []string{"new", "delete"},
})
if !r.OK {
t.Fatalf("patch disabled_commands failed: %s", r.Error)
}
got := e.GetDisabledCommands()
sort.Strings(got)
if len(got) != 2 || got[0] != "delete" || got[1] != "new" {
t.Fatalf("disabled_commands = %v, want [delete new]", got)
}
}
func TestMgmt_ProjectPatch_AdminFrom(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project", "tok", map[string]any{
"admin_from": "user123",
})
if !r.OK {
t.Fatalf("patch admin_from failed: %s", r.Error)
}
e.userRolesMu.RLock()
got := e.adminFrom
e.userRolesMu.RUnlock()
if got != "user123" {
t.Fatalf("adminFrom = %q, want user123", got)
}
}
func TestMgmt_ProjectPatch_Language(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
for _, lang := range []string{"zh", "ja", "es", "zh-TW", "en"} {
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project", "tok", map[string]any{
"language": lang,
})
if !r.OK {
t.Fatalf("patch language=%s failed: %s", lang, r.Error)
}
}
if e.i18n.CurrentLang() != LangEnglish {
t.Fatalf("final lang = %s, want en", e.i18n.CurrentLang())
}
}
func TestMgmt_ProjectDetail_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project", "tok", nil)
if r.OK {
t.Fatal("expected POST on project detail to fail")
}
}
// ── Project Delete edge cases ──
func TestMgmt_ProjectDelete_Success(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
var removed string
mgmt.SetRemoveProject(func(name string) error {
removed = name
return nil
})
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project", "tok")
if !r.OK {
t.Fatalf("delete project failed: %s", r.Error)
}
if removed != "test-project" {
t.Fatalf("removed = %q, want test-project", removed)
}
var data map[string]any
json.Unmarshal(r.Data, &data)
if data["restart_required"] != true {
t.Fatal("expected restart_required=true")
}
}
func TestMgmt_ProjectDelete_Error(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetRemoveProject(func(name string) error {
return errors.New("cannot remove last project")
})
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project", "tok")
if r.OK {
t.Fatal("expected error from removeProject")
}
if !strings.Contains(r.Error, "cannot remove last project") {
t.Fatalf("error = %q", r.Error)
}
}
// ── Session switch edge cases ──
func TestMgmt_SessionSwitch_Success(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
s1 := e.sessions.GetOrCreateActive("user1")
s2 := e.sessions.NewSession("user1", "second session")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions/switch", "tok", map[string]string{
"session_key": "user1",
"session_id": s2.ID,
})
if !r.OK {
t.Fatalf("switch failed: %s", r.Error)
}
current := e.sessions.GetOrCreateActive("user1")
if current.ID == s1.ID {
t.Fatal("session did not switch")
}
}
func TestMgmt_SessionSwitch_MissingFields(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions/switch", "tok", map[string]string{
"session_key": "user1",
})
if r.OK {
t.Fatal("expected error for missing session_id")
}
if !strings.Contains(r.Error, "required") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_SessionSwitch_InvalidSessionID(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
e.sessions.GetOrCreateActive("user1")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions/switch", "tok", map[string]string{
"session_key": "user1",
"session_id": "nonexistent-id",
})
if r.OK {
t.Fatal("expected error for nonexistent session_id")
}
}
func TestMgmt_SessionSwitch_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/sessions/switch", "tok")
if r.OK {
t.Fatal("expected GET on session switch to fail")
}
}
func TestMgmt_SessionSwitch_InvalidJSON(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/projects/test-project/sessions/switch", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
// ── Session detail edge cases ──
func TestMgmt_SessionDetail_NotFound(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/sessions/nonexistent-id", "tok")
if r.OK {
t.Fatal("expected 404 for nonexistent session")
}
}
func TestMgmt_SessionDetail_DeleteNotFound(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project/sessions/nonexistent-id", "tok")
if r.OK {
t.Fatal("expected 404 for deleting nonexistent session")
}
}
func TestMgmt_SessionDetail_MethodNotAllowed(t *testing.T) {
_, ts, e := testManagementServer(t, "tok")
s := e.sessions.GetOrCreateActive("user1")
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/sessions/"+s.ID, "tok", nil)
if r.OK {
t.Fatal("expected POST on session detail to fail")
}
}
func TestMgmt_SessionCreate_InvalidJSON(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/projects/test-project/sessions", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_Sessions_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project/sessions", "tok")
if r.OK {
t.Fatal("expected DELETE on sessions list to fail")
}
}
// ── Provider edge cases ──
func TestMgmt_ProjectProviders_PostInvalidJSON(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/projects/proj/providers", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_ProjectProviders_DeleteNotFound(t *testing.T) {
agent := &stubProviderAgent{
providers: []ProviderConfig{{Name: "openai"}},
active: "openai",
}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtDelete(t, ts.URL+"/api/v1/projects/proj/providers/nonexistent", "tok")
if r.OK {
t.Fatal("expected 404 for nonexistent provider")
}
}
func TestMgmt_ProjectProviders_MethodNotAllowed(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPut(t, ts.URL+"/api/v1/projects/proj/providers", "tok", nil)
if r.OK {
t.Fatal("expected PUT on providers list to fail")
}
}
// ── Provider Refs edge cases ──
func TestMgmt_ProjectProviderRefs_PutInvalidJSON(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mgmt.SetSaveProviderRefs(func(proj string, refs []string) error { return nil })
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/projects/proj/provider-refs", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_ProjectProviderRefs_PutSaveError(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mgmt.SetSaveProviderRefs(func(proj string, refs []string) error {
return errors.New("disk full")
})
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPut(t, ts.URL+"/api/v1/projects/proj/provider-refs", "tok", map[string]any{
"provider_refs": []string{"shared"},
})
if r.OK {
t.Fatal("expected error from save")
}
if !strings.Contains(r.Error, "disk full") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_ProjectProviderRefs_PutSuccess(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
var savedRefs []string
mgmt.SetSaveProviderRefs(func(proj string, refs []string) error {
savedRefs = refs
return nil
})
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtPut(t, ts.URL+"/api/v1/projects/proj/provider-refs", "tok", map[string]any{
"provider_refs": []string{"shared-1", "shared-2"},
})
if !r.OK {
t.Fatalf("put provider-refs failed: %s", r.Error)
}
if len(savedRefs) != 2 {
t.Fatalf("savedRefs = %v", savedRefs)
}
}
func TestMgmt_ProjectProviderRefs_MethodNotAllowed(t *testing.T) {
agent := &stubProviderAgent{providers: []ProviderConfig{{Name: "a"}}}
e := NewEngine("proj", agent, nil, "", LangEnglish)
mgmt := NewManagementServer(0, "tok", nil)
mgmt.RegisterEngine("proj", e)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/projects/", mgmt.wrap(mgmt.handleProjectRoutes))
ts := httptest.NewServer(mux)
defer ts.Close()
r := mgmtDelete(t, ts.URL+"/api/v1/projects/proj/provider-refs", "tok")
if r.OK {
t.Fatal("expected DELETE on provider-refs to fail")
}
}
// ── Users edge cases ──
func TestMgmt_ProjectUsers_PatchValid(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project/users", "tok", map[string]any{
"default_role": "member",
"roles": map[string]any{
"member": map[string]any{
"user_ids": []string{"uid-member-1"},
},
"admin": map[string]any{
"user_ids": []string{"uid-admin"},
},
},
})
if !r.OK {
t.Fatalf("patch users failed: %s", r.Error)
}
}
func TestMgmt_ProjectUsers_PatchInvalidRoleConfig(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPatch(t, ts.URL+"/api/v1/projects/test-project/users", "tok", map[string]any{
"default_role": "nonexistent",
"roles": map[string]any{
"admin": map[string]any{
"user_ids": []string{"uid-admin"},
},
},
})
if r.OK {
t.Fatal("expected error when default_role doesn't match any defined role")
}
if !strings.Contains(r.Error, "invalid users config") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_ProjectUsers_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/projects/test-project/users", "tok")
if r.OK {
t.Fatal("expected DELETE on users to fail")
}
}
// ── Global Providers edge cases ──
func TestMgmt_GlobalProviders_GetError(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetListGlobalProviders(func() ([]GlobalProviderInfo, error) {
return nil, errors.New("db connection lost")
})
r := mgmtGet(t, ts.URL+"/api/v1/providers", "tok")
if r.OK {
t.Fatal("expected error from list")
}
if !strings.Contains(r.Error, "db connection lost") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_GlobalProviders_PostInvalidJSON(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetAddGlobalProvider(func(info GlobalProviderInfo) error { return nil })
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/providers", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_GlobalProviders_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/providers", "tok")
if r.OK {
t.Fatal("expected DELETE on /providers to fail")
}
}
func TestMgmt_GlobalProviders_UpdateNotFound(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetUpdateGlobalProvider(func(name string, info GlobalProviderInfo) error {
return errors.New("not found: " + name)
})
r := mgmtPut(t, ts.URL+"/api/v1/providers/nope", "tok", map[string]string{"model": "x"})
if r.OK {
t.Fatal("expected 404")
}
}
func TestMgmt_GlobalProviders_UpdateInvalidJSON(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetUpdateGlobalProvider(func(name string, info GlobalProviderInfo) error { return nil })
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/providers/test", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_GlobalProviders_DeleteNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/providers/anything", "tok")
if r.OK {
t.Fatal("expected error when removeGlobalProvider is nil")
}
}
func TestMgmt_GlobalProviders_DeleteSuccess(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
var deleted string
mgmt.SetRemoveGlobalProvider(func(name string) error {
deleted = name
return nil
})
r := mgmtDelete(t, ts.URL+"/api/v1/providers/relay-old", "tok")
if !r.OK {
t.Fatalf("delete global provider failed: %s", r.Error)
}
if deleted != "relay-old" {
t.Fatalf("deleted = %q, want relay-old", deleted)
}
}
func TestMgmt_GlobalProviders_RouteMethodNotAllowed(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetUpdateGlobalProvider(func(name string, info GlobalProviderInfo) error { return nil })
r := mgmtPost(t, ts.URL+"/api/v1/providers/test-prov", "tok", nil)
if r.OK {
t.Fatal("expected POST on /providers/{name} to fail")
}
}
// ── Heartbeat edge cases ──
func TestMgmt_Heartbeat_PauseResumeRun(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
// pause/resume/run on unconfigured project → 404
for _, action := range []string{"pause", "resume", "run"} {
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/heartbeat/"+action, "tok", nil)
if r.OK {
t.Fatalf("expected 404 for heartbeat %s on unconfigured project", action)
}
}
}
func TestMgmt_Heartbeat_IntervalTooSmall(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
r := mgmtPost(t, ts.URL+"/api/v1/projects/test-project/heartbeat/interval", "tok",
map[string]any{"minutes": 0})
if r.OK {
t.Fatal("expected error for minutes < 1")
}
if !strings.Contains(r.Error, "minutes must be >= 1") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_Heartbeat_IntervalInvalidJSON(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/projects/test-project/heartbeat/interval", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_Heartbeat_UnknownAction(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/heartbeat/unknown", "tok")
if r.OK {
t.Fatal("expected 404 for unknown heartbeat action")
}
}
func TestMgmt_Heartbeat_MethodNotAllowed(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
hs := NewHeartbeatScheduler("")
mgmt.SetHeartbeatScheduler(hs)
r := mgmtGet(t, ts.URL+"/api/v1/projects/test-project/heartbeat/pause", "tok")
if r.OK {
t.Fatal("expected GET on pause to fail")
}
}
// ── Cron edge cases ──
func TestMgmt_Cron_PostMissingCronExpr(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtPost(t, ts.URL+"/api/v1/cron", "tok", map[string]any{
"project": "test-project",
"prompt": "hello",
})
if r.OK {
t.Fatal("expected error for missing cron_expr")
}
if !strings.Contains(r.Error, "cron_expr is required") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_Cron_PostMissingPromptAndExec(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtPost(t, ts.URL+"/api/v1/cron", "tok", map[string]any{
"project": "test-project",
"cron_expr": "0 9 * * *",
})
if r.OK {
t.Fatal("expected error for missing prompt and exec")
}
}
func TestMgmt_Cron_PostPromptAndExecMutuallyExclusive(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtPost(t, ts.URL+"/api/v1/cron", "tok", map[string]any{
"project": "test-project",
"cron_expr": "0 9 * * *",
"prompt": "hello",
"exec": "ls -la",
})
if r.OK {
t.Fatal("expected error when both prompt and exec are set")
}
if !strings.Contains(r.Error, "mutually exclusive") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_Cron_PostInvalidJSON(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/cron", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_Cron_MethodNotAllowed(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtDelete(t, ts.URL+"/api/v1/cron", "tok")
if r.OK {
t.Fatal("expected DELETE on /cron to fail")
}
}
func TestMgmt_CronByID_MethodNotAllowed(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtGet(t, ts.URL+"/api/v1/cron/some-id", "tok")
if r.OK {
t.Fatal("expected GET on /cron/{id} to fail")
}
}
func TestMgmt_CronPatch_InvalidJSON(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
req, _ := http.NewRequest("PATCH", ts.URL+"/api/v1/cron/some-id", strings.NewReader("{bad"))
req.Header.Set("Authorization", "Bearer tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r mgmtResponse
json.NewDecoder(resp.Body).Decode(&r)
if r.OK {
t.Fatal("expected error for invalid JSON")
}
}
func TestMgmt_CronByID_EmptyID(t *testing.T) {
mgmt, ts, e := testManagementServer(t, "tok")
store, _ := NewCronStore(t.TempDir())
cs := NewCronScheduler(store)
cs.RegisterEngine("test-project", e)
mgmt.SetCronScheduler(cs)
r := mgmtDelete(t, ts.URL+"/api/v1/cron/", "tok")
if r.OK {
t.Fatal("expected error for empty cron id")
}
}
// ── Project routes: empty project name ──
func TestMgmt_ProjectRoutes_EmptyProjectName(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/projects/", "tok")
// /projects/ with empty trailing slash is dispatched to handleProjectRoutes
// which returns "project name required" error.
if r.OK {
t.Fatal("expected error for empty project name in project routes")
}
}
// ── Reload edge cases ──
func TestMgmt_Reload_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtGet(t, ts.URL+"/api/v1/reload", "tok")
if r.OK {
t.Fatal("expected GET on reload to fail")
}
}
func TestMgmt_Reload_NoReloadFunc(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/reload", "tok", nil)
if !r.OK {
t.Fatalf("reload with nil reloadFunc should succeed: %s", r.Error)
}
}
// ── CC-Switch edge cases ──
func TestMgmt_CCSwitchProviders_PostNotConfigured(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtPost(t, ts.URL+"/api/v1/providers/cc-switch", "tok", map[string]any{
"names": []string{"relay-1"},
})
if r.OK {
t.Fatal("expected error when cc-switch not configured")
}
}
func TestMgmt_CCSwitchProviders_PostMissingNames(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetListCCSwitchProviders(func() ([]CCSwitchProviderInfo, error) {
return nil, nil
})
mgmt.SetAddGlobalProvider(func(info GlobalProviderInfo) error { return nil })
r := mgmtPost(t, ts.URL+"/api/v1/providers/cc-switch", "tok", map[string]any{
"names": []string{},
})
if r.OK {
t.Fatal("expected error for empty names")
}
if !strings.Contains(r.Error, "names is required") {
t.Fatalf("error = %q", r.Error)
}
}
func TestMgmt_CCSwitchProviders_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
r := mgmtDelete(t, ts.URL+"/api/v1/providers/cc-switch", "tok")
if r.OK {
t.Fatal("expected DELETE on cc-switch to fail")
}
}
// TestMgmt_SetupWeixinPoll_RejectsMalformedAPIURL is a regression test for a
// nil-pointer panic in handleSetupWeixinPoll. The handler did
// `u, _ := url.Parse(apiBase + "/")` and then immediately called
// `u.JoinPath(...)`. For inputs like "://" or "%zz", url.Parse returns a nil
// URL plus an error; the discarded error meant the next line crashed the
// management server with `runtime error: invalid memory address or nil
// pointer dereference`. handleSetupWeixinBegin already validated this same
// field; this test pins the symmetric handling here.
func TestMgmt_SetupWeixinPoll_RejectsMalformedAPIURL(t *testing.T) {
mgmt := NewManagementServer(0, "", nil)
for _, bad := range []string{"://", "://malformed", "%zz"} {
body := map[string]any{
"qr_key": "abc",
"api_url": bad,
}
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(body); err != nil {
t.Fatalf("encode body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/setup/weixin/poll", buf)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Recover any panic so the test reports a meaningful failure rather
// than crashing the test binary, then assert the handler returned a
// 4xx (not 5xx and not a panic) for the bad input.
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("handleSetupWeixinPoll panicked on api_url=%q: %v", bad, r)
}
}()
mgmt.handleSetupWeixinPoll(w, req)
}()
if w.Code != http.StatusBadRequest {
t.Errorf("api_url=%q: status=%d, want %d (body=%s)", bad, w.Code, http.StatusBadRequest, w.Body.String())
}
}
}