1db21fb899
- 生产/测试 WeKnora 地址、租户 ID、知识库 ID 由静态 env 收敛为版本化 UI 设置,保留修订号与操作人审计 - 新增 serviceverify:保存前真调目标端点探测,端点无效才拒绝;网络抖动或无凭据仅告警 - 迁移 000042(生产 WeKnora 端点)/000043(服务目标);补齐设置候选与校验契约用例
452 lines
17 KiB
Go
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)
|
|
}
|
|
}
|