1db21fb899
- 生产/测试 WeKnora 地址、租户 ID、知识库 ID 由静态 env 收敛为版本化 UI 设置,保留修订号与操作人审计 - 新增 serviceverify:保存前真调目标端点探测,端点无效才拒绝;网络抖动或无凭据仅告警 - 迁移 000042(生产 WeKnora 端点)/000043(服务目标);补齐设置候选与校验契约用例
124 lines
5.8 KiB
Go
124 lines
5.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/serviceverify"
|
|
)
|
|
|
|
// 真端点 + 真库的回环:证明「保存服务配置」不是格式校验,而是拿生产地址真查了
|
|
// WeKnora(列库接口)——存在才落库,不存在必须被拒且一行都不写。
|
|
// 默认跳过,只有显式给环境变量时才跑(命中真实生产端点):
|
|
//
|
|
// TEST_DATABASE_URL=<一次性库>
|
|
// SERVICEVERIFY_LIVE_BASE_URL=https://weknora-test.dobot.cn
|
|
// SERVICEVERIFY_LIVE_API_KEY_FILE=/path/to/key
|
|
// SERVICEVERIFY_LIVE_KNOWLEDGE_BASE_ID=<真实知识库 ID>
|
|
// SERVICEVERIFY_LIVE_TENANT_ID=<该库真实租户 ID>
|
|
func TestUISettingsWriteReadBackWithLiveVerification(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
baseURL := os.Getenv("SERVICEVERIFY_LIVE_BASE_URL")
|
|
keyFile := os.Getenv("SERVICEVERIFY_LIVE_API_KEY_FILE")
|
|
knowledgeBaseID := os.Getenv("SERVICEVERIFY_LIVE_KNOWLEDGE_BASE_ID")
|
|
tenantID := os.Getenv("SERVICEVERIFY_LIVE_TENANT_ID")
|
|
if databaseURL == "" || baseURL == "" || keyFile == "" || knowledgeBaseID == "" || tenantID == "" {
|
|
t.Skip("live verification environment is not configured")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var before int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM ui_setting_revisions`).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
|
|
handler, err := NewHandler(Config{
|
|
Store: postgresstore.NewStore(pool),
|
|
Now: func() time.Time { return now },
|
|
ServiceTargetVerifier: serviceverify.New(serviceverify.Config{
|
|
ProductionAPIKeyFile: keyFile,
|
|
Timeout: 10 * time.Second,
|
|
}),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1) 真实存在的知识库 + 真实租户:必须落库,并把知识库名回显给界面。
|
|
body := fmt.Sprintf(`{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"production_weknora_base_url":%q,"production_tenant_id":%q,"production_knowledge_base_id":%q}`, baseURL, tenantID, knowledgeBaseID)
|
|
create := httptest.NewRecorder()
|
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ui-settings/candidates", bytes.NewBufferString(body))
|
|
createRequest.Header.Set("X-Governance-Action", "ui-settings")
|
|
createRequest.Header.Set("Idempotency-Key", "op-live-verify-ok")
|
|
handler.ServeHTTP(create, createRequest)
|
|
if create.Code != http.StatusCreated {
|
|
t.Fatalf("verified save status=%d body=%s", create.Code, create.Body.String())
|
|
}
|
|
responseBody := create.Body.String()
|
|
if !strings.Contains(responseBody, `"status":"verified"`) || !strings.Contains(responseBody, `"blocked":false`) {
|
|
t.Fatalf("verified save must report a real verification: %s", responseBody)
|
|
}
|
|
var afterCreate int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM ui_setting_revisions`).Scan(&afterCreate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterCreate != before+1 {
|
|
t.Fatalf("verified save revisions=%d want %d", afterCreate, before+1)
|
|
}
|
|
|
|
// 2) 真实端点回答"没有这个知识库":必须 400,且一行都不写。
|
|
missingKB := "kb-live-missing-9f2c1"
|
|
missingBody := fmt.Sprintf(`{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"production_weknora_base_url":%q,"production_tenant_id":%q,"production_knowledge_base_id":%q}`, baseURL, tenantID, missingKB)
|
|
refused := httptest.NewRecorder()
|
|
refusedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ui-settings/candidates", bytes.NewBufferString(missingBody))
|
|
refusedRequest.Header.Set("X-Governance-Action", "ui-settings")
|
|
refusedRequest.Header.Set("Idempotency-Key", "op-live-verify-missing")
|
|
handler.ServeHTTP(refused, refusedRequest)
|
|
if refused.Code != http.StatusBadRequest || !strings.Contains(refused.Body.String(), `"status":"missing"`) {
|
|
t.Fatalf("refused save status=%d body=%s", refused.Code, refused.Body.String())
|
|
}
|
|
var afterRefusal int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM ui_setting_revisions`).Scan(&afterRefusal); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterRefusal != afterCreate {
|
|
t.Fatalf("a refused save must not write: revisions=%d want %d", afterRefusal, afterCreate)
|
|
}
|
|
|
|
// 3) 知识库真存在但租户不符:也必须拒("ID 存在"不等于"ID 是你的")。
|
|
mismatchBody := fmt.Sprintf(`{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"production_weknora_base_url":%q,"production_tenant_id":%q,"production_knowledge_base_id":%q}`, baseURL, "20000", knowledgeBaseID)
|
|
mismatch := httptest.NewRecorder()
|
|
mismatchRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ui-settings/candidates", bytes.NewBufferString(mismatchBody))
|
|
mismatchRequest.Header.Set("X-Governance-Action", "ui-settings")
|
|
mismatchRequest.Header.Set("Idempotency-Key", "op-live-verify-mismatch")
|
|
handler.ServeHTTP(mismatch, mismatchRequest)
|
|
if mismatch.Code != http.StatusBadRequest || !strings.Contains(mismatch.Body.String(), `"status":"tenant_mismatch"`) {
|
|
t.Fatalf("tenant mismatch save status=%d body=%s", mismatch.Code, mismatch.Body.String())
|
|
}
|
|
var afterMismatch int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM ui_setting_revisions`).Scan(&afterMismatch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterMismatch != afterCreate {
|
|
t.Fatalf("a tenant mismatch must not write: revisions=%d want %d", afterMismatch, afterCreate)
|
|
}
|
|
t.Logf("live verification ok: kb=%s tenant=%s", knowledgeBaseID, tenantID)
|
|
}
|