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

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)
}