package gateway import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "testing/fstest" "time" "github.com/gorilla/websocket" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // cfgDefault builds the minimum config used across tests. func cfgDefault() *config.Config { return &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} } // buildEnv constructs a Server + httptest server from an explicit cfg/deps, // defaulting Auth to a real JWTService and Bus to a real in-process bus. func buildEnv(t *testing.T, cfg *config.Config, deps Deps) (*Server, *httptest.Server, *auth.JWTService) { t.Helper() if cfg == nil { cfg = cfgDefault() } if deps.Auth == nil { deps.Auth = auth.New(config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}) } if deps.Bus == nil { deps.Bus = bus.New() } srv, err := New(cfg, deps) if err != nil { t.Fatalf("new server: %v", err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) jwt, _ := deps.Auth.(*auth.JWTService) return srv, ts, jwt } // buildServerOnly constructs a Server without an httptest server (used for // start/shutdown lifecycle tests). func buildServerOnly(t *testing.T, cfg *config.Config, deps Deps) *Server { t.Helper() srv, err := New(cfg, deps) if err != nil { t.Fatalf("new server: %v", err) } return srv } // mustJWT builds a real JWTService for a test secret. func mustJWT(t *testing.T) *auth.JWTService { t.Helper() return auth.New(config.AuthConfig{JWTSecret: testSecret}) } // signAny issues a token for sub under the given role via a real JWTService. func signAny(t *testing.T, jwt *auth.JWTService, sub string, role auth.Role) string { t.Helper() tok, err := jwt.Sign(sub, role, time.Hour) if err != nil { t.Fatalf("sign: %v", err) } return tok } // doRaw performs a request with a raw (already-encoded) body. func doRaw(t *testing.T, method, url, raw, token string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(method, url, strings.NewReader(raw)) if err != nil { t.Fatalf("new request: %v", err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp, b } // --- Mocks ------------------------------------------------------------------ // stubAuth wraps a real JWTService but lets Sign/Verify fail on demand. type stubAuth struct { jwt *auth.JWTService signErr error verifyErr error } func (s *stubAuth) Sign(sub string, role auth.Role, ttl time.Duration) (string, error) { if s.signErr != nil { return "", s.signErr } return s.jwt.Sign(sub, role, ttl) } func (s *stubAuth) Verify(token string) (*auth.Claims, error) { if s.verifyErr != nil { return nil, s.verifyErr } return s.jwt.Verify(token) } func (s *stubAuth) Authorize(required ...auth.Role) func(http.Handler) http.Handler { return s.jwt.Authorize(required...) } // stubCaps mimics CapabilityStore with a fixed list and a forced SetPolicy error. type stubCaps struct { list []capreg.Capability setErr error } func (s *stubCaps) List(kind capreg.Kind) []capreg.Capability { if kind == "" { return s.list } out := make([]capreg.Capability, 0) for _, cap := range s.list { if cap.Kind == kind { out = append(out, cap) } } return out } func (s *stubCaps) SetPolicy(name string, p capreg.ToolPolicy) error { if s.setErr != nil { return s.setErr } return nil } // stubSessions mimics SessionStore. type stubSessions struct { list []session.Session err error } func (s *stubSessions) List(ctx context.Context) ([]session.Session, error) { return s.list, s.err } // stubPersonas mimics PersonaStore, returning domain characters that the gateway // maps to PersonaSummary. type stubPersonas struct { list []personareg.Character } func (s *stubPersonas) List() []personareg.Character { return s.list } // Persona satisfies PersonaStore; tests don't rely on visual assets, so it // always reports no persona found (empty visuals, safe fallback). func (s *stubPersonas) Persona(string) (*loader.Persona, bool) { return nil, false } // stubMemories mimics MemoryStore, returning domain memory items that the gateway // maps to MemorySummary. type stubMemories struct { list []memory.MemoryItem err error } func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err } // stubRag mimics RagStore, returning domain docs/hits the gateway maps to its // admin wire shape. type stubRag struct { docs []knowledge.Doc hits []knowledge.Hit err error } func (s *stubRag) ListDocuments(ctx context.Context) ([]knowledge.Doc, error) { return s.docs, s.err } func (s *stubRag) Search(ctx context.Context, query string) ([]knowledge.Hit, error) { return s.hits, s.err } func (s *stubRag) IndexDocument(ctx context.Context, doc knowledge.Doc) error { return s.err } func (s *stubRag) DeleteDocument(ctx context.Context, id string) error { return s.err } // stubIoT mimics IoTStore. type stubIoT struct { list []iot.Device err error } func (s *stubIoT) List(ctx context.Context) ([]iot.Device, error) { return s.list, s.err } // stubAuditor mimics SubagentAuditor for the subagent_ask route. type stubAuditor struct { res *orchestration.AuditResult err error } func (s *stubAuditor) AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error) { return s.res, s.err } // stubTaskOutputs mimics TaskOutputStore for the task_output_query route. type stubTaskOutputs struct { ev task.OutputEvent err error } func (s *stubTaskOutputs) TaskOutput(ctx context.Context, id string) (task.OutputEvent, error) { return s.ev, s.err } // stubChannel implements sdk.Channel. type stubChannel struct{} func (stubChannel) Name() string { return "qq" } func (stubChannel) Connect(ctx context.Context) error { return nil } func (stubChannel) Disconnect(ctx context.Context) error { return nil } func (stubChannel) IsConnected() bool { return true } func (stubChannel) HealthCheck(ctx context.Context) error { return nil } var _ sdk.Channel = stubChannel{} // stubBotChannel mimics a live kook adapter that resolved its bot identity via // GET /api/v3/user/me on Connect (surfaced through Config()). type stubBotChannel struct{} func (stubBotChannel) Name() string { return "kook:conf0" } func (stubBotChannel) Connect(ctx context.Context) error { return nil } func (stubBotChannel) Disconnect(ctx context.Context) error { return nil } func (stubBotChannel) IsConnected() bool { return true } func (stubBotChannel) HealthCheck(ctx context.Context) error { return nil } func (stubBotChannel) Config() map[string]any { return map[string]any{"bot_id": "bot-7", "bot_username": "bot7", "bot_nickname": "小K"} } var _ sdk.Channel = stubBotChannel{} // captureRunner records the sessionID each Run call sees. type captureRunner struct { got []string res *loop.Result err error } func (c *captureRunner) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) { c.got = append(c.got, sessionID) return c.res, c.err } // plainWriter is a bare ResponseWriter implementing neither Flusher nor Hijacker. type plainWriter struct{} func (plainWriter) Header() http.Header { return http.Header{} } func (plainWriter) Write(b []byte) (int, error) { return len(b), nil } func (plainWriter) WriteHeader(code int) {} // --- Server construction / validation -------------------------------------- func TestNewValidation(t *testing.T) { t.Parallel() jwt := mustJWT(t) if _, err := New(nil, Deps{Auth: &stubAuth{jwt: jwt}}); err == nil { t.Fatal("nil config must error") } if _, err := New(&config.Config{}, Deps{}); err == nil { t.Fatal("nil auth dependency must error") } } // --- Login ------------------------------------------------------------------ func TestLoginBadBody(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/auth/login", "{", "") if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestLoginSignError(t *testing.T) { t.Parallel() jwt := mustJWT(t) srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: &stubAuth{jwt: jwt, signErr: errors.New("boom")}}) _ = srv resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login", map[string]any{"username": "admin", "password": "secret"}, "") if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- Chat ------------------------------------------------------------------- func TestChatBadBody(t *testing.T) { t.Parallel() env := newEnv(t, &stubRunner{}, false) tok := env.token(t, auth.RoleOwner) resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/chat", "{", tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestChatStreamMode(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"sessionId": "s1", "text": "hi", "stream": true}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } data := string(decodeBody(t, body)["data"]) if !strings.Contains(data, `"stream":true`) { t.Fatalf("data = %s, want stream true", data) } } func TestChatRequestPersistsMode(t *testing.T) { t.Parallel() hist := &fakeHistory{} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{ Loop: &stubRunner{res: &loop.Result{Text: "回复", StopReason: "stop"}}, History: hist, }) tok := signAny(t, jwt, "acct", auth.RoleOwner) // The REST body carries the client's behavior mode (design/16 §5.1); the // orchestrator persists both the user turn and the assistant reply under it. resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } recs := hist.records() if len(recs) != 2 { t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs)) } if recs[0].Role != history.RoleUser || recs[0].Mode != "code" { t.Fatalf("user record = %+v, want role=user mode=code", recs[0]) } if recs[1].Role != history.RoleAssistant || recs[1].Mode != "code" { t.Fatalf("assistant record = %+v, want role=assistant mode=code", recs[1]) } } func TestChatDefaultsSessionToWeb(t *testing.T) { t.Parallel() cr := &captureRunner{res: &loop.Result{Text: "ok", Steps: 1, StopReason: "stop"}} env := newEnv(t, cr, false) tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": "hi"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if len(cr.got) != 1 || cr.got[0] != "web" { t.Fatalf("sessionIDs = %v, want [web]", cr.got) } } func TestChatRunnerError(t *testing.T) { t.Parallel() env := newEnv(t, &stubRunner{err: errors.New("boom")}, false) tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": "hi"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- List handlers --------------------------------------------------------- func TestListSessionsNonEmpty(t *testing.T) { t.Parallel() deps := Deps{Session: &stubSessions{list: []session.Session{{ Platform: "qq", ChannelID: "c1", SessionID: "s1", ActorRole: adapter.RoleOwner, Owner: true, IsGroup: false, MemWritable: true, }}}} srv, ts, jwt := buildEnv(t, cfgDefault(), deps) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) for _, path := range []string{"/api/v1/sessions", "/api/v1/admin/sessions"} { resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("%s status = %d, want 200 (body=%s)", path, resp.StatusCode, body) } var items []struct { Platform string `json:"platform"` SessionID string `json:"sessionId"` Owner bool `json:"owner"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode %s: %v", path, err) } if len(items) != 1 || items[0].Platform != "qq" || items[0].SessionID != "s1" || !items[0].Owner { t.Fatalf("%s items = %+v, want single qq/s1 owner", path, items) } } } func TestListSessionsUserError(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Session: &stubSessions{err: errors.New("boom")}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestListSessionsAdminError(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Session: &stubSessions{err: errors.New("boom")}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := string(decodeBody(t, body)["data"]); got != "[]" { t.Fatalf("data = %s, want []", got) } } func TestListPersonasNonEmpty(t *testing.T) { t.Parallel() deps := Deps{Personas: &stubPersonas{list: []personareg.Character{{ ID: "p1", Name: "苏梦", PersonaID: "p1", Default: true, Modes: []string{"chat"}, }}}} srv, ts, jwt := buildEnv(t, cfgDefault(), deps) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) for _, path := range []string{"/api/v1/personas", "/api/v1/admin/personas"} { resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("%s status = %d, want 200 (body=%s)", path, resp.StatusCode, body) } var items []struct { ID string `json:"id"` Name string `json:"name"` IsDefault bool `json:"isDefault"` Mode string `json:"mode"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode %s: %v", path, err) } if len(items) != 1 || items[0].Name != "苏梦" || items[0].ID != "p1" || !items[0].IsDefault || items[0].Mode != "chat" { t.Fatalf("%s items = %+v, want single 苏梦/p1/default/chat", path, items) } } } func TestAdminMemoriesNonEmpty(t *testing.T) { t.Parallel() deps := Deps{Memories: &stubMemories{list: []memory.MemoryItem{{ ID: "m1", PersonaID: "persona.a", Scope: memory.ScopeProject, Content: "note", }}}} srv, ts, jwt := buildEnv(t, cfgDefault(), deps) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var items []struct { ID string `json:"id"` Scope string `json:"scope"` Text string `json:"text"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode: %v", err) } if len(items) != 1 || items[0].Scope != "project" || items[0].Text != "note" { t.Fatalf("items = %+v, want single project/note", items) } } func TestAdminMemoriesErrorStillOK(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: &stubMemories{err: errors.New("boom")}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := string(decodeBody(t, body)["data"]); got != "[]" { t.Fatalf("data = %s, want []", got) } } // --- Admin: IoT ------------------------------------------------------------ func TestAdminIoTNonEmpty(t *testing.T) { t.Parallel() deps := Deps{IoT: &stubIoT{list: []iot.Device{{ ID: "d1", Name: "客厅灯", Type: "light", State: map[string]any{"power": "on"}, Online: true, }}}} srv, ts, jwt := buildEnv(t, cfgDefault(), deps) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/iot", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var items []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` State map[string]any `json:"state"` Online bool `json:"online"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode: %v", err) } if len(items) != 1 || items[0].Name != "客厅灯" || items[0].Type != "light" || !items[0].Online { t.Fatalf("items = %+v, want single 客厅灯/light/online", items) } if items[0].State["power"] != "on" { t.Fatalf("state = %+v, want power on", items[0].State) } } func TestAdminIoTError(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{IoT: &stubIoT{err: errors.New("boom")}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/iot", nil, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- Admin: channels ------------------------------------------------------- func TestAdminChannelsFromRegistry(t *testing.T) { t.Parallel() env := newEnv(t, nil, true) if err := env.caps.RegisterChannel(stubChannel{}); err != nil { t.Fatalf("register channel: %v", err) } // A channel-kind capability whose Value is not an sdk.Channel must be skipped. if err := env.caps.Register(capreg.Capability{Name: "badchan", Kind: capreg.KindChannel, Value: stubTool{}}); err != nil { t.Fatalf("register bad channel: %v", err) } tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/channels", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var items []struct { Name string `json:"name"` Connected bool `json:"connected"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode: %v", err) } if len(items) != 1 || items[0].Name != "qq" || !items[0].Connected { t.Fatalf("items = %+v, want single qq/connected", items) } } // TestAdminChannels_SurfacesBotIdentity verifies a connected kook adapter's // resolved bot identity (from user/me) reaches the accounts of GET /admin/channels // so the panel can show who the bot is. func TestAdminChannels_SurfacesBotIdentity(t *testing.T) { t.Parallel() caps := capreg.New() if err := caps.RegisterChannel(stubBotChannel{}); err != nil { t.Fatalf("register channel: %v", err) } store := &stubChannelConfigStore{ path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{ "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}}, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var items []struct { Name string `json:"name"` Accounts []AdminChannelAccount `json:"accounts"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode: %v", err) } if len(items) != 1 || items[0].Name != "kook" { t.Fatalf("items = %+v, want single kook row", items) } if len(items[0].Accounts) != 1 { t.Fatalf("accounts = %+v, want one", items[0].Accounts) } acct := items[0].Accounts[0] if acct.BotNickname != "小K" || acct.BotUsername != "bot7" || acct.BotID != "bot-7" { t.Errorf("account bot identity = %+v, want 小K/bot7/bot-7", acct) } } // --- Admin: policy errors -------------------------------------------------- func TestAdminSetPolicyUnavailable(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Registry _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminSetPolicyOtherError(t *testing.T) { t.Parallel() deps := Deps{Registry: &stubCaps{setErr: errors.New("boom")}} srv, ts, jwt := buildEnv(t, cfgDefault(), deps) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminSetPolicyBadBody(t *testing.T) { t.Parallel() env := newEnv(t, nil, true) tok := env.token(t, auth.RoleOwner) resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", "{", tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminSetPolicyMissingName(t *testing.T) { t.Parallel() env := newEnv(t, nil, true) tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"allowInGroup": true}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- CORS ------------------------------------------------------------------ func TestCORSAllowAll(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.AllowedOrigins = []string{"*"} srv, ts, _ := buildEnv(t, cfg, Deps{}) _ = srv req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil) req.Header.Set("Origin", "http://anywhere.example") resp := doHandler(t, req) if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { t.Fatalf("ACAO = %q, want *", got) } } func TestCORSWhitelistedOrigin(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.AllowedOrigins = []string{"http://allowed.example"} srv, ts, _ := buildEnv(t, cfg, Deps{}) _ = srv req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil) req.Header.Set("Origin", "http://allowed.example") resp := doHandler(t, req) if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://allowed.example" { t.Fatalf("ACAO = %q, want origin", got) } } func TestCORSRejectsUnknownOrigin(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.AllowedOrigins = []string{"http://allowed.example"} srv, ts, _ := buildEnv(t, cfg, Deps{}) _ = srv req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil) req.Header.Set("Origin", "http://evil.example") resp := doHandler(t, req) if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("ACAO = %q, want empty for unknown origin", got) } } func TestCORSPreflight(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.AllowedOrigins = []string{"http://allowed.example"} srv, ts, _ := buildEnv(t, cfg, Deps{}) _ = srv req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/api/v1/chat", nil) req.Header.Set("Origin", "http://allowed.example") req.Header.Set("Access-Control-Request-Method", "POST") resp := doHandler(t, req) if resp.StatusCode != http.StatusNoContent { t.Fatalf("status = %d, want 204", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://allowed.example" { t.Fatalf("ACAO = %q, want origin", got) } } // --- Recovery + statusRecorder -------------------------------------------- func TestRecoverPanicTo500(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) h := env.srv.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("boom") })) rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/x", nil)) if rr.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rr.Code) } wantErrorShape(t, rr.Body.Bytes()) } func TestStatusRecorderHijackUnsupported(t *testing.T) { t.Parallel() rec := &statusRecorder{ResponseWriter: plainWriter{}} conn, rw, err := rec.Hijack() if err == nil { t.Fatal("Hijack on a non-Hijacker must error") } if conn != nil || rw != nil { t.Fatalf("conn/rw = %v/%v, want nil", conn, rw) } } func TestStatusRecorderFlush(t *testing.T) { t.Parallel() // Flusher present → forwarded. rr := httptest.NewRecorder() (&statusRecorder{ResponseWriter: rr}).Flush() if !rr.Flushed { t.Fatal("Flush should have been forwarded to the underlying Flusher") } // No Flusher → no-op, no panic. (&statusRecorder{ResponseWriter: plainWriter{}}).Flush() } // --- SPA ------------------------------------------------------------------- func TestSPA(t *testing.T) { t.Parallel() front := fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("root")}, "assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")}, } srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front}) _ = srv // Real asset is served. resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "") if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" { t.Fatalf("asset status = %d body = %q, want 200 with js", resp.StatusCode, body) } // Unknown non-API path falls back to index.html. resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "") if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") { t.Fatalf("fallback status = %d body = %q, want 200 with index", resp.StatusCode, body) } // Root serves index.html. resp, body = doReq(t, http.MethodGet, ts.URL+"/", nil, "") if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") { t.Fatalf("root status = %d body = %q, want 200 with index", resp.StatusCode, body) } // API route is passed through untouched. resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("api status = %d, want 200 (body=%s)", resp.StatusCode, body) } } // --- WS: validation -------------------------------------------------------- func TestWSRequiresGET(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/ws/chat", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestWSInvalidJSON(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) conn := dialWS(t, env.ts.URL, tok) defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) if err := conn.WriteMessage(websocket.TextMessage, []byte("{not json")); err != nil { t.Fatalf("write invalid json: %v", err) } ev := readWS(t, conn) if ev.Type != "error" { t.Fatalf("type = %q, want error (frame=%+v)", ev.Type, ev) } } func TestWSAuthViaHeader(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat" hdr := http.Header{} hdr.Set("Authorization", "Bearer "+tok) conn, resp, err := websocket.DefaultDialer.Dial(wsURL, hdr) if err != nil { t.Fatalf("dial with header: %v (resp=%+v)", err, resp) } defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil { t.Fatalf("write mode_switch: %v", err) } if got := readWS(t, conn); got.Type != "mode_switch" { t.Fatalf("frame type = %q, want mode_switch", got.Type) } } func TestWSCloseDeregisters(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) conn := dialWS(t, env.ts.URL, tok) waitConnCount(t, env.srv, 1) conn.Close() waitConnCount(t, env.srv, 0) } func TestServerCloseShutsHub(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) conn := dialWS(t, env.ts.URL, tok) waitConnCount(t, env.srv, 1) env.srv.Close() waitConnCount(t, env.srv, 0) conn.Close() } // --- WS: hub unit tests ---------------------------------------------------- func TestHubStreamChat(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", Steps: 2, StopReason: "stop"}}, nil) c := newFakeConn(hb, 32) hb.register(c) hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": "hello"}) if ev := readFakeFrame(t, c); ev.Type != "stream_chunk" { t.Fatalf("first frame type = %q, want stream_chunk (frame=%+v)", ev.Type, ev) } if ev := readFakeFrame(t, c); ev.Type != "stream_end" { t.Fatalf("second frame type = %q, want stream_end (frame=%+v)", ev.Type, ev) } } func TestHubStreamChatPersistsMode(t *testing.T) { t.Parallel() hist := &fakeHistory{} hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", StopReason: "stop"}}, nil) hb.orchestrator = NewOrchestrator(OrchestratorOptions{ Run: func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) { return &loop.Result{Text: "hi", StopReason: "stop"}, nil }, History: hist, }) // The ws.chat payload carries the client's behavior mode (design/16 §5.1); // streamChat threads it into the orchestrator, which persists both the user // turn and the assistant reply under it. hb.streamChat(context.Background(), map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && len(hist.records()) < 2 { time.Sleep(time.Millisecond) } recs := hist.records() if len(recs) != 2 { t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs)) } if recs[0].Role != history.RoleUser || recs[0].Mode != "code" { t.Fatalf("user record = %+v, want role=user mode=code", recs[0]) } if recs[1].Role != history.RoleAssistant || recs[1].Mode != "code" { t.Fatalf("assistant record = %+v, want role=assistant mode=code", recs[1]) } } func TestHubBroadcastStream(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) c := newFakeConn(hb, 32) hb.register(c) // A text_delta StreamEvent on the OnStream→bus→relay path becomes a typed // message.delta frame carrying the delta text and a monotonically increasing // seq, leaving the legacy stream_chunk shape out of the way. hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0}) ev := readFakeFrame(t, c) if ev.Type != "message.delta" { t.Fatalf("frame type = %q, want message.delta (frame=%+v)", ev.Type, ev) } data, ok := ev.Data.(map[string]any) if !ok || data["delta"] != "Hel" { t.Fatalf("frame data = %+v, want delta=Hel", ev.Data) } if ev.Seq != 1 { t.Fatalf("frame seq = %d, want 1", ev.Seq) } // A tool_call event maps to tool_call.*; a non-StreamEvent payload is dropped. hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindToolCallEnd, ID: "toolu_1", Index: 0}) ev = readFakeFrame(t, c) if ev.Type != "tool_call.end" { t.Fatalf("second frame type = %q, want tool_call.end", ev.Type) } hb.broadcastStream("not-a-stream-event") if err := connHasNoFrame(c); err != nil { t.Fatalf("unexpected frame from non-StreamEvent payload: %v", err) } } func TestServerBroadcastCapacityAwaken(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) c := newFakeConn(env.srv.hub, 32) env.srv.hub.register(c) env.srv.BroadcastCapacityAwaken("web", "quiet", "awake", "web_search") ev := readFakeFrame(t, c) if ev.Type != "capacity_awaken" { t.Fatalf("frame type = %q, want capacity_awaken (frame=%+v)", ev.Type, ev) } if ev.SessionID != "web" { t.Fatalf("frame sessionId = %q, want web", ev.SessionID) } data, ok := ev.Data.(map[string]any) if !ok || data["fromState"] != "quiet" || data["toState"] != "awake" || data["tool"] != "web_search" { t.Fatalf("frame data = %+v, want {fromState:quiet,toState:awake,tool:web_search}", ev.Data) } } // TestServerBroadcastPersistedSplit verifies BroadcastPersisted routes the event // type by session: the main web session and its web sub-sessions broadcast // `message_persisted` (web chat realtime sync), while an isolated channel adapter // session (sess-xxx) broadcasts `channel_persisted` (IM adapter log page) so it is // naturally ignored by the web chat handler. func TestServerBroadcastPersistedSplit(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) c := newFakeConn(env.srv.hub, 32) env.srv.hub.register(c) cases := []struct { name string sessionID string wantType string }{ {name: "main web session", sessionID: history.MainSessionID, wantType: "message_persisted"}, {name: "web sub session", sessionID: history.MainSessionID + ":abc123", wantType: "message_persisted"}, {name: "isolated channel session", sessionID: "sess-g", wantType: "channel_persisted"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env.srv.BroadcastPersisted(history.Message{ ID: "m-" + tc.sessionID, SessionID: tc.sessionID, Role: history.RoleUser, Content: "hello", Platform: "telegram", AdapterID: "telegram:abc", CreatedAt: 1000, }) ev := readFakeFrame(t, c) if ev.Type != tc.wantType { t.Fatalf("frame type = %q, want %q (frame=%+v)", ev.Type, tc.wantType, ev) } if ev.SessionID != tc.sessionID { t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, tc.sessionID) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %+v, want map", ev.Data) } msg, ok := data["message"].(map[string]any) if !ok { t.Fatalf("frame data.message = %+v, want map", data["message"]) } if msg["content"] != "hello" || msg["adapterId"] != "telegram:abc" { t.Fatalf("frame message = %+v, want content/hello + adapterId/telegram:abc", msg) } }) } } func TestHubStreamChatIdle(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) // nil runner c := newFakeConn(hb, 32) hb.register(c) hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": ""}) if ev := readFakeFrame(t, c); ev.Type != "stream_end" { t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev) } } func TestHubStreamChatError(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{err: errors.New("boom")}, nil) c := newFakeConn(hb, 32) hb.register(c) hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": "hi"}) if ev := readFakeFrame(t, c); ev.Type != "stream_end" { t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev) } } func TestHubRelayChatBranch(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", Steps: 1, StopReason: "stop"}}, nil) c := newFakeConn(hb, 32) hb.register(c) if err := hb.relay(context.Background(), bus.Event{Subject: wsChatSubject, Payload: map[string]any{"sessionId": "s1", "text": "hi"}}); err != nil { t.Fatalf("relay: %v", err) } // relay runs streamChat in a goroutine; read with a deadline. if ev := readFakeFrame(t, c); ev.Type != "stream_chunk" { t.Fatalf("frame type = %q, want stream_chunk (frame=%+v)", ev.Type, ev) } if ev := readFakeFrame(t, c); ev.Type != "stream_end" { t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev) } } func TestHubRoute(t *testing.T) { t.Parallel() hb := newTestHub(nil, bus.New()) hb.auditor = &stubAuditor{res: &orchestration.AuditResult{ AgentID: "sub-1", Status: orchestration.StatusRunning, Goal: "do it", Mode: orchestration.AuditFull, Messages: []model.Message{model.TextMessage(model.RoleUser, "hi")}, }} hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{ ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out", Stderr: "err"}, }} c := newFakeConn(hb, 32) hb.register(c) // subagent_ask → subagent_event (real audit data, not a hardcoded pending). c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)}) if ev := readFakeFrame(t, c); ev.Type != "subagent_event" { t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type) } // subagent_ask with no auditor → clear error, never a fabricated pending. hb.auditor = nil c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)}) if ev := readFakeFrame(t, c); ev.Type != "error" { t.Fatalf("no-auditor subagent frame type = %q, want error", ev.Type) } hb.auditor = &stubAuditor{res: &orchestration.AuditResult{}} // task_output_query → task_output with real output. c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)}) if ev := readFakeFrame(t, c); ev.Type != "task_output" { t.Fatalf("task frame type = %q, want task_output", ev.Type) } // task_output_query with no task store → clear error, never an empty {}. hb.taskOutputs = nil c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)}) if ev := readFakeFrame(t, c); ev.Type != "error" { t.Fatalf("no-task store frame type = %q, want error", ev.Type) } hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out"}}} // voice_input with no voice service → clear error, never a crash or a drop. c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"x"}`)}) if ev := readFakeFrame(t, c); ev.Type != "error" { t.Fatalf("no-voice voice_input frame type = %q, want error", ev.Type) } // unknown → error (enqueueJSON). c.route(wsInbound{Type: "nope"}) if ev := readFakeFrame(t, c); ev.Type != "error" { t.Fatalf("unknown frame type = %q, want error", ev.Type) } // chat → publishes to bus (publishPublic non-nil path); no frame enqueued. c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"text":"hi"}`)}) if err := connHasNoFrame(c); err != nil { t.Fatalf("chat should not enqueue a frame directly: %v", err) } } // stubVoice is a deterministic VoiceService for voice route tests. type stubVoice struct { sttText string sttErr error tts voice.Audio ttsErr error } func (s *stubVoice) STT(_ context.Context, _ voice.Audio) (string, error) { return s.sttText, s.sttErr } func (s *stubVoice) TTS(_ context.Context, _ string) (voice.Audio, error) { return s.tts, s.ttsErr } func (s *stubVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil } func TestHubVoiceInputTurn(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil) hb.voice = &stubVoice{sttText: "你好", tts: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}} c := newFakeConn(hb, 32) hb.register(c) // "aGVsbG8=" decodes to "hello" — a valid base64 audio payload. c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)}) // runVoiceTurn runs in a goroutine; read with a deadline. ev := readFakeFrame(t, c) if ev.Type != "voice_output" { t.Fatalf("voice frame type = %q, want voice_output (frame=%+v)", ev.Type, ev) } r, ok := ev.Data.(map[string]any) if !ok || r["text"] != "你好" { t.Fatalf("voice transcript = %+v, want 你好", ev.Data) } if r["reply"] != "你好" { t.Fatalf("voice reply = %+v, want 你好", ev.Data) } if r["audio"] == "" || r["audio"] == nil { t.Fatalf("voice audio missing from reply: %+v", ev.Data) } } func TestHubBridgeDeviceUpdate(t *testing.T) { t.Parallel() b := bus.New() hb := newTestHub(nil, b) c := newFakeConn(hb, 32) hb.register(c) hb.start() t.Cleanup(hb.close) if err := b.Publish(context.Background(), "iot.device_update", map[string]any{"message": "灯开了"}); err != nil { t.Fatalf("publish: %v", err) } if ev := readFakeFrame(t, c); ev.Type != "device_update" { t.Fatalf("type = %q, want device_update (frame=%+v)", ev.Type, ev) } } func TestHubBridgeSubagentProgress(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) c := newFakeConn(hb, 32) hb.register(c) if err := hb.relay(context.Background(), bus.Event{ Subject: "cyrene.agent.orchestrator.event", Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{ AgentID: "sub-1", Status: orchestration.StatusDone, RoleID: "engineer", Mode: "chat", }}, }); err != nil { t.Fatalf("relay: %v", err) } if ev := readFakeFrame(t, c); ev.Type != "subagent_event" { t.Fatalf("type = %q, want subagent_event (frame=%+v)", ev.Type, ev) } // An internal ask subject must never surface to clients. if err := hb.relay(context.Background(), bus.Event{ Subject: "cyrene.agent.a.b.ask", Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{AgentID: "sub-1"}}, }); err != nil { t.Fatalf("relay ask: %v", err) } if err := connHasNoFrame(c); err != nil { t.Fatalf("internal ask must not be broadcast: %v", err) } } func TestPublishPublicNilBus(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) // nil bus c := newFakeConn(hb, 32) // Must not panic and must not enqueue anything. c.publishPublic(wsChatSubject, map[string]any{"text": "hi"}) } func TestRawData(t *testing.T) { t.Parallel() if rawData(nil) != nil { t.Fatal("rawData(nil) should be nil") } if rawData(json.RawMessage(`{"a":1}`)) == nil { t.Fatal("rawData(non-empty) should be non-nil") } } func TestOriginChecker(t *testing.T) { t.Parallel() allowed := map[string]bool{"http://ok.example": true} checker := originChecker(allowed, false) if !checker(reqWithOrigin("")) { t.Fatal("empty origin should be allowed") } if !checker(reqWithOrigin("http://ok.example")) { t.Fatal("whitelisted origin should be allowed") } if checker(reqWithOrigin("http://bad.example")) { t.Fatal("unknown origin should be rejected") } all := originChecker(allowed, true) if !all(reqWithOrigin("http://bad.example")) { t.Fatal("allow-all should permit any origin") } } func TestOriginCheckerSameHost(t *testing.T) { t.Parallel() allowed := map[string]bool{"http://ok.example": true} checker := originChecker(allowed, false) // Same host (exact) — production mode serving embedded frontend on the gateway port. req := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) req.Host = "localhost:10942" req.Header.Set("Origin", "http://localhost:10942") if !checker(req) { t.Fatal("same host origin should be allowed") } // Loopback alias: host is 127.0.0.1 but client origin uses localhost. req2 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) req2.Host = "127.0.0.1:10942" req2.Header.Set("Origin", "http://localhost:10942") if !checker(req2) { t.Fatal("localhost<->127.0.0.1 loopback alias should be allowed") } // Different host must still be rejected (not a same-origin, not whitelisted). req3 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) req3.Host = "evil.example:10942" req3.Header.Set("Origin", "http://localhost:10942") if checker(req3) { t.Fatal("cross-host origin should be rejected") } // Same host, different port: still same-origin (same physical host), so allow. req4 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) req4.Host = "localhost:8080" req4.Header.Set("Origin", "http://localhost:10942") if !checker(req4) { t.Fatal("same host (different port) should be allowed — port is not a cross-origin boundary") } } func TestWSTokenExtraction(t *testing.T) { t.Parallel() if got := wsToken(reqWithQuery("token", "abc")); got != "abc" { t.Fatalf("query token = %q, want abc", got) } r := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) r.Header.Set("Authorization", "Bearer xyz") if got := wsToken(r); got != "xyz" { t.Fatalf("header token = %q, want xyz", got) } if got := wsToken(httptest.NewRequest(http.MethodGet, "/ws/chat", nil)); got != "" { t.Fatalf("empty token = %q, want empty", got) } } // --- Start / ListenAndServe lifecycle ------------------------------------- func TestStartAndShutdown(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.Server = config.ServerConfig{Host: "127.0.0.1", Port: 0} srv := buildServerOnly(t, cfg, Deps{Auth: mustJWT(t)}) ctx, cancel := context.WithCancel(context.Background()) errCh, err := srv.Start(ctx) if err != nil { t.Fatalf("start: %v", err) } time.Sleep(100 * time.Millisecond) cancel() select { case err := <-errCh: if err != nil { t.Fatalf("listen/serve returned error: %v", err) } case <-time.After(5 * time.Second): t.Fatal("server did not shut down on cancel") } } // --- Helpers --------------------------------------------------------------- func doHandler(t *testing.T, req *http.Request) *http.Response { t.Helper() resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() _, _ = io.ReadAll(resp.Body) return resp } func dialWS(t *testing.T, baseURL, token string) *websocket.Conn { t.Helper() wsURL := "ws" + strings.TrimPrefix(baseURL, "http") + "/ws/chat?token=" + token conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial ws: %v", err) } return conn } func waitConnCount(t *testing.T, srv *Server, want int) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if srv.hub.connCount() == want { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("conn count = %d, want %d", srv.hub.connCount(), want) } func newTestHub(runner Chatter, b bus.Bus) *hub { return &hub{ conns: make(map[*wsConn]struct{}), bus: b, runner: runner, log: sourcedLog(nil), } } func newFakeConn(hb *hub, buf int) *wsConn { return &wsConn{ send: make(chan []byte, buf), done: make(chan struct{}), h: hb, } } func readFakeFrame(t *testing.T, c *wsConn) wsEvent { t.Helper() select { case b := <-c.send: var ev wsEvent if err := json.Unmarshal(b, &ev); err != nil { t.Fatalf("decode frame %s: %v", b, err) } return ev case <-time.After(2 * time.Second): t.Fatal("timed out waiting for a frame") return wsEvent{} } } func connHasNoFrame(c *wsConn) error { select { case b := <-c.send: return errors.New("unexpected frame: " + string(b)) default: return nil } } func reqWithOrigin(origin string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set("Origin", origin) return r } func reqWithQuery(k, v string) *http.Request { return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil) } // --- RAG knowledge base ---------------------------------------------------- func TestAdminRagListsAndSearches(t *testing.T) { t.Parallel() docs := []knowledge.Doc{{ID: "p1", Name: "开发时间线.md", Desc: "milestones", Chunks: 3, Embedded: true}} hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Chapter: "M4", Text: "RAG 检索测试", Score: 1.0}} srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{docs: docs, hits: hits}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) // List only. resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) } var list struct { Documents []AdminRagDoc `json:"documents"` Hits []AdminRagHit `json:"hits"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil { t.Fatalf("decode list: %v (body=%s)", err, body) } if len(list.Documents) != 1 || list.Documents[0].Name != "开发时间线.md" { t.Fatalf("documents = %+v, want one 开发时间线.md", list.Documents) } if list.Hits != nil { t.Fatalf("hits = %+v, want omitted without query", list.Hits) } // List + search via ?query=. resp2, body2 := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag?query=rag", nil, tok) if resp2.StatusCode != http.StatusOK { t.Fatalf("search status = %d, want 200 (body=%s)", resp2.StatusCode, body2) } var search struct { Documents []AdminRagDoc `json:"documents"` Hits []AdminRagHit `json:"hits"` } if err := json.Unmarshal(decodeBody(t, body2)["data"], &search); err != nil { t.Fatalf("decode search: %v (body=%s)", err, body2) } if len(search.Hits) != 1 || search.Hits[0].DocName != "开发时间线.md" { t.Fatalf("hits = %+v, want one hit", search.Hits) } } func TestAdminRagQueryPost(t *testing.T) { t.Parallel() hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Text: "RAG 检索测试", Score: 1.0}} srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{hits: hits}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Query string `json:"query"` Hits []AdminRagHit `json:"hits"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if res.Query != "rag" || len(res.Hits) != 1 { t.Fatalf("response = %+v, want query=rag with 1 hit", res) } } func TestAdminRagEmptyWhenUnwired(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) // List degrades to empty (never a 500). resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) } var list struct { Documents []AdminRagDoc `json:"documents"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(list.Documents) != 0 { t.Fatalf("documents = %+v, want empty", list.Documents) } // Query reports a clear 501 (backend not implemented). resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok) if resp2.StatusCode != http.StatusNotImplemented { t.Fatalf("query status = %d, want 501 (body=%s)", resp2.StatusCode, body2) } wantErrorShape(t, body2) if !strings.Contains(string(body2), "not_implemented") { t.Fatalf("error body = %s, want not_implemented code", body2) } } // TestAdminRagDeleteUnsupportedStore501 asserts a store that cannot delete is // answered with an honest 501 rather than a fabricated success. func TestAdminRagDeleteUnsupportedStore501(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{err: knowledge.ErrWriteUnsupported}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": "p1"}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) if !strings.Contains(string(body), "not_implemented") { t.Fatalf("error body = %s, want not_implemented code", body) } } func TestAdminRagDeleteRequiresID(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{}}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": " "}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } }