package gateway import ( "context" "encoding/json" "io" "log/slog" "net/http" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "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/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) func gwTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // newMemoryStoreForGateway opens an isolated in-memory SQLite DB and builds a // real memory.Store over it so the owner write path (which type-asserts to // *memory.Store) exercises the real Add. func newMemoryStoreForGateway(t *testing.T) *memory.Store { t.Helper() ctx := context.Background() d, err := db.Open(ctx, ":memory:", gwTestLogger()) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = d.Close() }) s, err := memory.NewStore(ctx, d, gwTestLogger()) if err != nil { t.Fatalf("memory.NewStore: %v", err) } return s } func newRagStoreForGateway(t *testing.T) *knowledge.Store { t.Helper() ctx := context.Background() d, err := db.Open(ctx, ":memory:", gwTestLogger()) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = d.Close() }) ps, err := projectindex.New(ctx, d, gwTestLogger()) if err != nil { t.Fatalf("projectindex.New: %v", err) } return knowledge.New(knowledge.Deps{Projects: ps, Log: gwTestLogger()}) } func newHistoryStoreForGateway(t *testing.T) *history.Store { t.Helper() ctx := context.Background() d, err := db.Open(ctx, ":memory:", gwTestLogger()) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = d.Close() }) s, err := history.NewStore(ctx, d, gwTestLogger()) if err != nil { t.Fatalf("history.NewStore: %v", err) } return s } func TestAdminAddMemoryWritesAndLists(t *testing.T) { t.Parallel() store := newMemoryStoreForGateway(t) srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"personaId": "cyrene", "content": "她喜欢喝冰美式", "scope": "persona"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("add status = %d, want 201 (body=%s)", resp.StatusCode, body) } var added struct { ID string `json:"id"` Scope string `json:"scope"` Text string `json:"text"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil { t.Fatalf("decode add: %v (body=%s)", err, body) } if added.ID == "" || added.Scope != "persona" || added.Text != "她喜欢喝冰美式" { t.Fatalf("added = %+v, want persona/她喜欢喝冰美式", added) } // The freshly added item is now listed. listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok) if listResp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody) } var items []MemorySummary if err := json.Unmarshal(decodeBody(t, listBody)["data"], &items); err != nil { t.Fatalf("decode list: %v", err) } if len(items) != 1 || items[0].Text != "她喜欢喝冰美式" || items[0].Scope != "persona" { t.Fatalf("items = %+v, want one persona 她喜欢喝冰美式", items) } } func TestAdminAddMemoryRejectsBadInput(t *testing.T) { t.Parallel() store := newMemoryStoreForGateway(t) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // Blank content. resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"personaId": "cyrene", "content": " "}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank content status = %d, want 400", resp.StatusCode) } // Invalid scope. resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"personaId": "cyrene", "content": "x", "scope": "bogus"}, tok) if resp2.StatusCode != http.StatusBadRequest { t.Fatalf("bogus scope status = %d, want 400", resp2.StatusCode) } } func TestAdminAddMemoryUnwired501(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no memory store tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"content": "x"}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("unwired status = %d, want 501", resp.StatusCode) } } func TestAdminIndexRagImportsAndLists(t *testing.T) { t.Parallel() rag := newRagStoreForGateway(t) srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag}) _ = srv tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag", map[string]any{"name": "内部知识库.md", "desc": "团队规范", "content": "第一段:命名规范。\n\n第二段:评审流程。"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("index status = %d, want 201 (body=%s)", resp.StatusCode, body) } // The document now shows up in the list (imported + chunked). listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok) if listResp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody) } var res AdminRagResponse if err := json.Unmarshal(decodeBody(t, listBody)["data"], &res); err != nil { t.Fatalf("decode list: %v", err) } if len(res.Documents) != 1 || res.Documents[0].Name != "内部知识库.md" || res.Documents[0].Chunks != 2 { t.Fatalf("documents = %+v, want one 内部知识库.md with 2 chunks", res.Documents) } // It is immediately queryable (keyword/line path). qResp, qBody := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "评审流程"}, tok) if qResp.StatusCode != http.StatusOK { t.Fatalf("query status = %d, want 200 (body=%s)", qResp.StatusCode, qBody) } var qres struct { Hits []AdminRagHit `json:"hits"` } if err := json.Unmarshal(decodeBody(t, qBody)["data"], &qres); err != nil { t.Fatalf("decode query: %v", err) } if len(qres.Hits) == 0 || qres.Hits[0].DocName != "内部知识库.md" { t.Fatalf("hits = %+v, want a hit on 内部知识库.md", qres.Hits) } } func TestAdminIndexRagRejectsBlankNameOrContent(t *testing.T) { t.Parallel() rag := newRagStoreForGateway(t) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag", map[string]any{"name": " ", "content": "x"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank name status = %d, want 400", resp.StatusCode) } resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag", map[string]any{"name": "x", "content": " "}, tok) if resp2.StatusCode != http.StatusBadRequest { t.Fatalf("blank content status = %d, want 400", resp2.StatusCode) } } func TestAdminIndexRagUnwired501(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag", map[string]any{"name": "x", "content": "y"}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("unwired status = %d, want 501", resp.StatusCode) } } // newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh // temp models.json, seeded by the env config. It is the real stack the admin // write endpoint persists to and reloads. func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM { t.Helper() return model.NewRuntimeLLM(modelsPath, env, gwTestLogger()) } func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { t.Parallel() modelsPath := filepath.Join(t.TempDir(), "models.json") env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"} runtime := newLlmRuntimeForGateway(t, modelsPath, env) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } var res struct { Provider AdminLlmProvider `json:"provider"` Persisted bool `json:"persisted"` Path string `json:"path"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode add: %v", err) } if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-chat" { t.Fatalf("add res = %+v", res) } // models.json now holds the saved group (promoted to index 0 = active). groups, warn := runtimeconfig.LoadModels(modelsPath, env) if warn != nil { t.Fatalf("load models warn = %v", warn) } if len(groups) == 0 || groups[0].ID != "deepseek" || groups[0].APIKey != "sk-ds" { t.Fatalf("file groups = %+v, want deepseek first with sk-ds", groups) } // The running stack reflects the new active provider/model (hot reload). if runtime.Provider() != "openai" || runtime.Model() != "deepseek-chat" { t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-chat", runtime.Provider(), runtime.Model()) } // GET /admin/llm reflects the saved group and never leaks any API key. gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200", gResp.StatusCode) } var list AdminLlmConfig if err := json.Unmarshal(decodeBody(t, gBody)["data"], &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-chat" { t.Fatalf("list = %+v, want deepseek-chat first", list.Providers) } if strings.Contains(string(gBody), "sk-ds") || strings.Contains(string(gBody), "sk-env") { t.Fatalf("API key leaked in GET /admin/llm: %s", gBody) } } func TestAdminAddLlmProviderSwitchProvider(t *testing.T) { t.Parallel() modelsPath := filepath.Join(t.TempDir(), "models.json") env := config.LLMConfig{Provider: "openai", BaseURL: "https://x", APIKey: "sk", Model: "m"} runtime := newLlmRuntimeForGateway(t, modelsPath, env) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // Saving an anthropic group makes it the active provider/model. resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" { t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model()) } // The saved group is active (index 0) and anthropic. if runtime.Group().Provider != "anthropic" { t.Fatalf("active group = %+v, want anthropic", runtime.Group()) } } func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) { t.Parallel() modelsPath := filepath.Join(t.TempDir(), "models.json") runtime := newLlmRuntimeForGateway(t, modelsPath, config.LLMConfig{}) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // Missing model. resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"baseUrl": "https://x"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body) } // Invalid provider. resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok) if resp2.StatusCode != http.StatusBadRequest { t.Fatalf("invalid provider status = %d, want 400", resp2.StatusCode) } } func TestAdminAddLlmProviderUnwired501(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"baseUrl": "https://x", "model": "m"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("unwired status = %d, want 500", resp.StatusCode) } } func TestAdminListSessionsHistoryFirst(t *testing.T) { t.Parallel() h := newHistoryStoreForGateway(t) srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: h}) _ = 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) } var items []historySessionSummary if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode: %v", err) } if len(items) == 0 { t.Fatal("admin sessions list is empty; main session should always exist") } if items[0].Type != "main" || items[0].ID == "" { t.Fatalf("first session = %+v, want a main session with id", items[0]) } }