Files
knowledge-governance/internal/platform/weknora/client_test.go
T
姚章浩 1db21fb899 feat(settings): 服务配置四项化并保存时真校验
- 生产/测试 WeKnora 地址、租户 ID、知识库 ID 由静态 env 收敛为版本化 UI 设置,保留修订号与操作人审计
- 新增 serviceverify:保存前真调目标端点探测,端点无效才拒绝;网络抖动或无凭据仅告警
- 迁移 000042(生产 WeKnora 端点)/000043(服务目标);补齐设置候选与校验契约用例
2026-09-30 10:54:07 +08:00

452 lines
17 KiB
Go

package weknora
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestClientGetKnowledgeUsesPinnedOriginAndAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/knowledge/knowledge-1" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("X-API-Key"); got != "test-api-key" {
t.Fatalf("X-API-Key = %q", got)
}
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("Authorization must be absent, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-1","knowledge_base_id":"kb-1","parse_status":"processing","file_hash":"abc","updated_at":"2026-09-04T01:00:00Z","metadata":{"source":"legacy","process_overrides":{"parser":"mineru"}}}}`))
}))
defer server.Close()
client, err := NewClient(ClientConfig{
BaseURL: server.URL,
APIKey: "test-api-key",
Timeout: time.Second,
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
knowledge, err := client.GetKnowledge(context.Background(), "knowledge-1")
if err != nil {
t.Fatalf("GetKnowledge() error = %v", err)
}
if knowledge.ID != "knowledge-1" || knowledge.KnowledgeBaseID != "kb-1" || knowledge.ParseStatus != "processing" || knowledge.FileHash != "abc" {
t.Fatalf("knowledge = %#v", knowledge)
}
if knowledge.Metadata["source"] != "legacy" {
t.Fatalf("metadata = %#v", knowledge.Metadata)
}
}
func TestClientReadsKnowledgeBaseInventoryAndDetail(t *testing.T) {
const inventory = `{"success":true,"data":[{"id":"kb-1","name":"生产知识库","knowledge_count":4,"chunk_count":9,"tenant_id":10000}]}`
const detail = `{"success":true,"data":{"id":"kb-1","name":"生产知识库","knowledge_count":4,"chunk_count":9,"tenant_id":10000}}`
requests := make([]string, 0, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-API-Key"); got != "test-api-key" {
t.Fatalf("X-API-Key = %q", got)
}
requests = append(requests, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/knowledge-bases" {
_, _ = w.Write([]byte(inventory))
return
}
_, _ = w.Write([]byte(detail))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
bases, err := client.ListKnowledgeBases(context.Background())
if err != nil {
t.Fatalf("ListKnowledgeBases() error = %v", err)
}
if len(bases) != 1 || bases[0].ID != "kb-1" || bases[0].Name != "生产知识库" || bases[0].TenantID != 10000 {
t.Fatalf("bases = %#v", bases)
}
base, err := client.GetKnowledgeBase(context.Background(), "kb-1")
if err != nil {
t.Fatalf("GetKnowledgeBase() error = %v", err)
}
if base.ID != "kb-1" || base.TenantID != 10000 || base.Name != "生产知识库" {
t.Fatalf("base = %#v", base)
}
if got := strings.Join(requests, ","); got != "GET /api/v1/knowledge-bases,GET /api/v1/knowledge-bases/kb-1" {
t.Fatalf("requests = %q", got)
}
}
func TestClientReportsMissingKnowledgeBaseAsNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":1003,"message":"knowledge base not found"}}`))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.GetKnowledgeBase(context.Background(), "kb-missing"); err == nil {
t.Fatal("expected an error for a missing knowledge base")
} else {
var httpErr *HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusNotFound {
t.Fatalf("error = %v, want HTTPError 404", err)
}
}
}
func TestGetKnowledgeBaseRejectsUnsafeID(t *testing.T) {
client, err := NewClient(ClientConfig{BaseURL: "http://127.0.0.1:1", APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.GetKnowledgeBase(context.Background(), "kb-1/../../etc/passwd"); err == nil {
t.Fatal("expected an error for an unsafe knowledge base ID")
}
}
func TestNewClientRequiresHTTPSExceptForLoopbackTests(t *testing.T) {
for _, rawURL := range []string{
"http://example.com",
"ftp://example.com",
"https://user:password@example.com",
"https://example.com?redirect=other",
} {
if _, err := NewClient(ClientConfig{BaseURL: rawURL, APIKey: "test-api-key", Timeout: time.Second}); err == nil {
t.Fatalf("NewClient(%q) error = nil", rawURL)
}
}
if _, err := NewClient(ClientConfig{BaseURL: "https://example.com", APIKey: "test-api-key", Timeout: time.Second}); err != nil {
t.Fatalf("NewClient(https) error = %v", err)
}
if _, err := NewClient(ClientConfig{BaseURL: "http://127.0.0.1:8080", APIKey: "key", Timeout: time.Second}); err != nil {
t.Fatalf("NewClient(loopback) error = %v", err)
}
}
func TestNewClientAllowsExplicitSingleLabelDockerHTTPOnly(t *testing.T) {
if _, err := NewClient(ClientConfig{
BaseURL: "http://app:8080",
APIKey: "key",
Timeout: time.Second,
AllowDockerHTTP: true,
}); err != nil {
t.Fatalf("NewClient(explicit docker HTTP) error = %v", err)
}
for _, rawURL := range []string{
"http://app:8080",
"http://host.docker.internal:8080",
"http://10.0.0.5:8080",
"http://example.com:8080",
} {
allow := rawURL != "http://app:8080"
_, err := NewClient(ClientConfig{
BaseURL: rawURL,
APIKey: "key",
Timeout: time.Second,
AllowDockerHTTP: allow,
})
if err == nil {
t.Fatalf("NewClient(%q) error = nil, want insecure HTTP rejected", rawURL)
}
}
}
func TestClientUploadFileStreamsExpectedMultipartFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/knowledge-bases/kb-1/knowledge/file" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if r.ContentLength != -1 {
t.Fatalf("ContentLength = %d, want streaming request", r.ContentLength)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm() error = %v", err)
}
file, header, err := r.FormFile("file")
if err != nil {
t.Fatalf("FormFile() error = %v", err)
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
t.Fatal(err)
}
if header.Filename != "safe.md" || string(body) != "document body" {
t.Fatalf("upload = filename %q body %q", header.Filename, body)
}
if r.FormValue("metadata") != `{"content_sha256":"abc","publication_id":"publication-1"}` {
t.Fatalf("metadata = %q", r.FormValue("metadata"))
}
if r.FormValue("fileName") != "safe.md" || r.FormValue("channel") != "governance" {
t.Fatalf("fileName/channel = %q/%q", r.FormValue("fileName"), r.FormValue("channel"))
}
if r.FormValue("process_config") != `{"graph_enabled":false}` {
t.Fatalf("process_config = %q", r.FormValue("process_config"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-new","knowledge_base_id":"kb-1","parse_status":"pending"}}`))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
knowledge, err := client.UploadFile(context.Background(), UploadRequest{
KnowledgeBaseID: "kb-1",
FileName: "safe.md",
Content: bytes.NewBufferString("document body"),
Metadata: map[string]string{
"publication_id": "publication-1",
"content_sha256": "abc",
},
Channel: "governance",
ProcessConfig: map[string]any{"graph_enabled": false},
})
if err != nil {
t.Fatalf("UploadFile() error = %v", err)
}
if knowledge.ID != "knowledge-new" || knowledge.KnowledgeBaseID != "kb-1" || knowledge.ParseStatus != "pending" {
t.Fatalf("knowledge = %#v", knowledge)
}
}
func TestClientUploadFileReturnsTypedConflictWithoutClaimingSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"success":false,"data":{"id":"candidate-1","knowledge_base_id":"kb-1"},"code":"duplicate"}`))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
knowledge, err := client.UploadFile(context.Background(), UploadRequest{
KnowledgeBaseID: "kb-1",
FileName: "safe.md",
Content: bytes.NewBufferString("document body"),
})
if !errors.Is(err, ErrConflict) {
t.Fatalf("UploadFile() error = %v, want ErrConflict", err)
}
if knowledge.ID != "candidate-1" {
t.Fatalf("candidate knowledge = %#v", knowledge)
}
}
func TestClientDeleteKnowledgeUsesPinnedOriginAndAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v1/knowledge/knowledge-1" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("X-API-Key"); got != "test-api-key" {
t.Fatalf("X-API-Key = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
if err := client.DeleteKnowledge(context.Background(), "knowledge-1"); err != nil {
t.Fatalf("DeleteKnowledge() error = %v", err)
}
if err := client.DeleteKnowledge(context.Background(), "../knowledge-1"); err == nil {
t.Fatal("DeleteKnowledge() accepted an unsafe knowledge ID")
}
}
func TestClientProvidesPaginatedInventoryChunkAndScopedSearchReads(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/knowledge-bases/kb-1/knowledge":
if r.Method != http.MethodGet || r.URL.Query().Get("page") != "2" || r.URL.Query().Get("page_size") != "50" {
t.Fatalf("knowledge list request = %s %s", r.Method, r.URL.RawQuery)
}
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"knowledge-1","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1"}}],"total":51,"page":2,"page_size":50}`))
case "/api/v1/chunks/knowledge-1":
if r.Method != http.MethodGet || r.URL.Query().Get("page") != "1" || r.URL.Query().Get("page_size") != "100" {
t.Fatalf("chunk list request = %s %s", r.Method, r.URL.RawQuery)
}
types := r.URL.Query()["chunk_type"]
if len(types) != 2 || types[0] != "text" || types[1] != "image_ocr" {
t.Fatalf("chunk types = %#v", types)
}
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"chunk-1","knowledge_id":"knowledge-1","chunk_type":"text"}],"total":1,"page":1,"page_size":100}`))
case "/api/v1/knowledge-search":
if r.Method != http.MethodPost {
t.Fatalf("search method = %s", r.Method)
}
var request SearchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if request.Query != "unique question" || len(request.KnowledgeIDs) != 1 || request.KnowledgeIDs[0] != "knowledge-1" {
t.Fatalf("search request = %#v", request)
}
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"chunk-1","knowledge_id":"knowledge-1","score":0.02,"match_type":2,"chunk_type":"text"}]}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
items, total, err := client.ListKnowledge(context.Background(), "kb-1", 2, 50)
if err != nil || total != 51 || len(items) != 1 || items[0].Metadata["publication_id"] != "publication-1" {
t.Fatalf("ListKnowledge() = %#v, %d, %v", items, total, err)
}
chunks, total, err := client.ListChunks(context.Background(), "knowledge-1", 1, 100, []string{"text", "image_ocr"})
if err != nil || total != 1 || len(chunks) != 1 || chunks[0].KnowledgeID != "knowledge-1" {
t.Fatalf("ListChunks() = %#v, %d, %v", chunks, total, err)
}
results, err := client.SearchKnowledge(context.Background(), SearchRequest{Query: "unique question", KnowledgeIDs: []string{"knowledge-1"}})
if err != nil || len(results) != 1 || results[0].KnowledgeID != "knowledge-1" || results[0].Score != 0.02 {
t.Fatalf("SearchKnowledge() = %#v, %v", results, err)
}
}
func TestClientRejectsRedirectWithoutForwardingAPIKey(t *testing.T) {
var targetHits atomic.Int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits.Add(1)
if r.Header.Get("X-API-Key") != "" {
t.Error("redirect target received X-API-Key")
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer origin.Close()
client, err := NewClient(ClientConfig{BaseURL: origin.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
if _, err := client.GetKnowledge(context.Background(), "knowledge-1"); err == nil {
t.Fatal("GetKnowledge() error = nil, want redirect rejected")
}
if targetHits.Load() != 0 {
t.Fatalf("redirect target hits = %d", targetHits.Load())
}
}
func TestClientBoundsAndRedactsRemoteResponses(t *testing.T) {
secret := "remote-secret-that-must-not-leak"
tests := []struct {
name string
body string
code int
}{
{name: "error body", code: http.StatusInternalServerError, body: secret},
{name: "malformed body", code: http.StatusOK, body: "{" + secret},
{name: "oversized body", code: http.StatusOK, body: strings.Repeat("x", 65)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.code)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second, MaxResponseBytes: 64})
if err != nil {
t.Fatal(err)
}
_, err = client.GetKnowledge(context.Background(), "knowledge-1")
if err == nil {
t.Fatal("GetKnowledge() error = nil")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("error leaked remote body: %v", err)
}
})
}
}
func TestClientClassifiesHTTPFailuresWithoutLeakingBody(t *testing.T) {
tests := []struct {
status int
retryable bool
}{
{status: http.StatusUnauthorized, retryable: false},
{status: http.StatusForbidden, retryable: false},
{status: http.StatusTooManyRequests, retryable: true},
{status: http.StatusInternalServerError, retryable: true},
}
for _, tt := range tests {
t.Run(http.StatusText(tt.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte("sensitive remote detail"))
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
_, err = client.GetKnowledge(context.Background(), "knowledge-1")
var httpErr *HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != tt.status {
t.Fatalf("GetKnowledge() error = %T %v", err, err)
}
if IsRetryable(err) != tt.retryable {
t.Fatalf("IsRetryable() = %v, want %v", IsRetryable(err), tt.retryable)
}
if strings.Contains(err.Error(), "sensitive remote detail") {
t.Fatalf("error leaked remote body: %v", err)
}
})
}
}
func TestClientClassifiesTimeoutAsRetryable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: 10 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
_, err = client.GetKnowledge(context.Background(), "knowledge-1")
if err == nil || !IsRetryable(err) {
t.Fatalf("GetKnowledge() error = %v, want retryable timeout", err)
}
}