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

282 lines
18 KiB
Go

package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
const serviceCandidateBody = `{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"test_weknora_base_url":"https://weknora.test.example","production_weknora_base_url":"https://weknora.prod.example","production_tenant_id":"10000","production_knowledge_base_id":"kb-prod-1"}`
const refreshOnlyCandidateBody = `{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2}`
func withOperatorRole(request *http.Request, role string) *http.Request {
identity := sessionIdentity{UserID: "user-1", Username: "operator-1", DisplayName: "Operator", Role: role}
return request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity))
}
func postUISettings(t *testing.T, handler http.Handler, path, body, key, role string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
request.Header.Set("X-Governance-Action", "ui-settings")
request.Header.Set("Idempotency-Key", key)
if role != "" {
request = withOperatorRole(request, role)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
// The four service fields (test endpoint, production endpoint, production tenant,
// production knowledge base) are the settings a knowledge engineer may not touch:
// authoring them must fail closed, per field, and only an admin's candidate may
// carry them into the store. Refresh-only candidates keep working for engineers.
func TestServiceConfigurationCandidateRequiresAdmin(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{
ID: "uis-candidate", State: "validated", TestWeKnoraBaseURL: "https://weknora.test.example",
ProductionWeKnoraBaseURL: "https://weknora.prod.example", ProductionTenantID: "10000", ProductionKnowledgeBaseID: "kb-prod-1", CreatedAt: now,
}}
handler, err := NewHandler(Config{Store: store, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
engineer := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-engineer", roleEngineer)
if engineer.Code != http.StatusForbidden || !strings.Contains(engineer.Body.String(), "service configuration changes require an admin") {
t.Fatalf("engineer service candidate status=%d body=%s", engineer.Code, engineer.Body.String())
}
if store.createUISettingsRequest.TestWeKnoraBaseURL != "" || store.createUISettingsRequest.ProductionWeKnoraBaseURL != "" || store.createUISettingsRequest.ProductionTenantID != "" || store.createUISettingsRequest.ProductionKnowledgeBaseID != "" {
t.Fatalf("engineer candidate reached the store: %#v", store.createUISettingsRequest)
}
// Every single field is gated on its own — an engineer cannot slip one in.
for name, body := range map[string]string{
"test endpoint": `{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"test_weknora_base_url":"https://weknora.test.example"}`,
"tenant id": `{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"production_tenant_id":"10000"}`,
"knowledge base": `{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"production_knowledge_base_id":"kb-prod-1"}`,
} {
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", body, "op-service-engineer-"+name, roleEngineer)
if response.Code != http.StatusForbidden {
t.Fatalf("engineer %s-only candidate status=%d body=%s", name, response.Code, response.Body.String())
}
}
admin := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-admin", roleAdmin)
if admin.Code != http.StatusCreated || store.createUISettingsRequest.ProductionWeKnoraBaseURL != "https://weknora.prod.example" || store.createUISettingsRequest.TestWeKnoraBaseURL != "https://weknora.test.example" || store.createUISettingsRequest.ProductionTenantID != "10000" || store.createUISettingsRequest.ProductionKnowledgeBaseID != "kb-prod-1" {
t.Fatalf("admin service candidate status=%d request=%#v body=%s", admin.Code, store.createUISettingsRequest, admin.Body.String())
}
// A refresh-only candidate carries no service field, so nothing changed and the
// engineer keeps their existing write path.
refreshStore := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-refresh", State: "validated", CreatedAt: now}}
refreshHandler, err := NewHandler(Config{Store: refreshStore, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
refresh := postUISettings(t, refreshHandler, "/api/v1/ui-settings/candidates", refreshOnlyCandidateBody, "op-service-engineer-refresh", roleEngineer)
if refresh.Code != http.StatusCreated || refreshStore.createUISettingsRequest.ProductionTenantID != "" {
t.Fatalf("engineer refresh-only candidate status=%d request=%#v body=%s", refresh.Code, refreshStore.createUISettingsRequest, refresh.Body.String())
}
}
// Activation is the second half of the same privilege: a revision that would flip
// any of the four service fields may only be activated by an admin, while
// refresh-only revisions keep working for engineers.
func TestServiceConfigurationActivationRequiresAdmin(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
for name, revision := range map[string]governance.UISettings{
"test endpoint": {ID: "uis-test-endpoint", State: "validated", TestWeKnoraBaseURL: "https://weknora.test.example"},
"tenant id": {ID: "uis-tenant", State: "validated", ProductionTenantID: "10000"},
"knowledge base": {ID: "uis-kb", State: "validated", ProductionKnowledgeBaseID: "kb-prod-1"},
"production peer endpoint": {ID: "uis-endpoint", State: "validated", ProductionWeKnoraBaseURL: "https://weknora.prod.example"},
} {
flipping := &fakeJobStore{activeUISettingsResult: governance.UISettings{ID: "settings-default", State: "active"}, revisionUISettingsResult: revision}
handler, err := NewHandler(Config{Store: flipping, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
engineer := postUISettings(t, handler, "/api/v1/ui-settings/"+revision.ID+"/activate", `{}`, "op-service-activate-eng-"+name, roleEngineer)
if engineer.Code != http.StatusForbidden || flipping.activateUISettingsID != "" {
t.Fatalf("engineer %s activation status=%d id=%q body=%s", name, engineer.Code, flipping.activateUISettingsID, engineer.Body.String())
}
admin := postUISettings(t, handler, "/api/v1/ui-settings/"+revision.ID+"/activate", `{}`, "op-service-activate-admin-"+name, roleAdmin)
if admin.Code != http.StatusOK || flipping.activateUISettingsID != revision.ID {
t.Fatalf("admin %s activation status=%d id=%q body=%s", name, admin.Code, flipping.activateUISettingsID, admin.Body.String())
}
}
refreshOnly := &fakeJobStore{
activeUISettingsResult: governance.UISettings{ID: "settings-default", State: "active", TestWeKnoraBaseURL: "https://weknora.test.example", ProductionWeKnoraBaseURL: "https://weknora.prod.example", ProductionTenantID: "10000", ProductionKnowledgeBaseID: "kb-prod-1"},
revisionUISettingsResult: governance.UISettings{ID: "uis-refresh", State: "validated", TestWeKnoraBaseURL: "https://weknora.test.example", ProductionWeKnoraBaseURL: "https://weknora.prod.example", ProductionTenantID: "10000", ProductionKnowledgeBaseID: "kb-prod-1"},
}
refreshHandler, err := NewHandler(Config{Store: refreshOnly, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
unchanged := postUISettings(t, refreshHandler, "/api/v1/ui-settings/uis-refresh/activate", `{}`, "op-refresh-activate-eng", roleEngineer)
if unchanged.Code != http.StatusOK || refreshOnly.activateUISettingsID != "uis-refresh" {
t.Fatalf("engineer refresh-only activation status=%d id=%q body=%s", unchanged.Code, refreshOnly.activateUISettingsID, unchanged.Body.String())
}
}
// Without auth (development socket) there is no identity to rank, so the existing
// dev behaviour must not become a 403 regression.
func TestServiceConfigurationStaysOpenOnAuthDisabledDevSocket(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-candidate", State: "validated", ProductionWeKnoraBaseURL: "https://weknora.prod.example", CreatedAt: now}}
handler, err := NewHandler(Config{Store: store, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-dev", "")
if response.Code != http.StatusCreated || store.createUISettingsRequest.ProductionKnowledgeBaseID != "kb-prod-1" {
t.Fatalf("auth-disabled candidate status=%d request=%#v body=%s", response.Code, store.createUISettingsRequest, response.Body.String())
}
}
// stubServiceTargetVerifier records what the save path asked and answers with a
// canned verdict, so the handler's policy — refuse a definitive no, tolerate an
// outage — is tested without a live endpoint.
type stubServiceTargetVerifier struct {
targets []governance.ServiceTarget
verification governance.ServiceTargetVerification
}
func (stub *stubServiceTargetVerifier) VerifyServiceTarget(_ context.Context, target governance.ServiceTarget) governance.ServiceTargetVerification {
stub.targets = append(stub.targets, target)
return stub.verification
}
// 保存必须用「生产地址 + 知识库 ID」真查一次:查到了才写入,并把知识库名回显给界面。
func TestServiceConfigurationSaveVerifiesProductionTarget(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
verifier := &stubServiceTargetVerifier{verification: governance.ServiceTargetVerification{
Status: governance.ServiceTargetVerified, Message: "已核验:生产地址上存在知识库「生产知识库」,租户 10000 一致",
KnowledgeBaseName: "生产知识库", ObservedTenantID: "10000", CheckedAt: now,
}}
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-candidate", State: "validated", CreatedAt: now}}
handler, err := NewHandler(Config{Store: store, ServiceTargetVerifier: verifier, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-verified", roleAdmin)
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `"status":"verified"`) || !strings.Contains(response.Body.String(), "生产知识库") {
t.Fatalf("verified save status=%d body=%s", response.Code, response.Body.String())
}
if len(verifier.targets) != 1 {
t.Fatalf("verifier calls = %d", len(verifier.targets))
}
want := governance.ServiceTarget{
TestBaseURL: "https://weknora.test.example", ProductionBaseURL: "https://weknora.prod.example",
ProductionTenantID: "10000", ProductionKnowledgeBaseID: "kb-prod-1",
}
if verifier.targets[0] != want {
t.Fatalf("verified target = %#v want %#v", verifier.targets[0], want)
}
if store.createUISettingsOperationID == "" {
t.Fatal("a verified save must still reach the store")
}
}
// 端点明确回答"没有这个知识库"时不允许落库:ID 不能靠格式校验混过去。
func TestServiceConfigurationSaveRefusesKnowledgeBaseTheEndpointDoesNotHave(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
verifier := &stubServiceTargetVerifier{verification: governance.ServiceTargetVerification{
Status: governance.ServiceTargetMissing, Blocked: true, Message: "未通过:生产地址上找不到知识库 ID「kb-prod-1」",
}}
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-candidate", State: "validated", CreatedAt: now}}
handler, err := NewHandler(Config{Store: store, ServiceTargetVerifier: verifier, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-refused", roleAdmin)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "找不到知识库") {
t.Fatalf("refused save status=%d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"blocked":true`) {
t.Fatalf("refusal must carry the verification detail: %s", response.Body.String())
}
if store.createUISettingsOperationID != "" {
t.Fatal("a refused save must not reach the store")
}
}
// 端点抖动(超时/网络/未配置凭据)只警告不拦:运维不能因为端点故障就改不了地址。
func TestServiceConfigurationSaveKeepsFlakyEndpointNonBlocking(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
verifier := &stubServiceTargetVerifier{verification: governance.ServiceTargetVerification{
Status: governance.ServiceTargetUnreachable, Message: "未核验:生产端点暂时不可用(超时),保存未被阻断",
}}
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-candidate", State: "validated", CreatedAt: now}}
handler, err := NewHandler(Config{Store: store, ServiceTargetVerifier: verifier, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-flaky", roleAdmin)
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `"status":"unreachable"`) {
t.Fatalf("flaky save status=%d body=%s", response.Code, response.Body.String())
}
if store.createUISettingsOperationID == "" {
t.Fatal("an outage must not stop the save")
}
}
// 激活前再查一次:候选可以躺几天,知识库可能已经被删;字段与线上一致的回放不重复核验。
func TestServiceConfigurationActivationReverifiesStagedTarget(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
verifier := &stubServiceTargetVerifier{verification: governance.ServiceTargetVerification{
Status: governance.ServiceTargetMissing, Blocked: true, Message: "未通过:生产地址上找不到知识库 ID「kb-prod-1」",
}}
store := &fakeJobStore{
activeUISettingsResult: governance.UISettings{ID: "settings-default", State: "active"},
revisionUISettingsResult: governance.UISettings{ID: "uis-kb", State: "validated", ProductionKnowledgeBaseID: "kb-prod-1"},
activatedUISettingsResult: governance.UISettings{ID: "uis-kb", State: "active", ProductionKnowledgeBaseID: "kb-prod-1"},
}
handler, err := NewHandler(Config{Store: store, ServiceTargetVerifier: verifier, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/uis-kb/activate", `{}`, "op-activate-reverify", roleAdmin)
if response.Code != http.StatusBadRequest || store.activateUISettingsOperationID != "" {
t.Fatalf("re-verified activation status=%d id=%q body=%s", response.Code, store.activateUISettingsOperationID, response.Body.String())
}
if len(verifier.targets) != 1 || verifier.targets[0].ProductionKnowledgeBaseID != "kb-prod-1" {
t.Fatalf("activation verification targets = %#v", verifier.targets)
}
// A replay (identical fields already live) must stay idempotent: no endpoint
// call, so an outage cannot turn a retry into a failure.
replayVerifier := &stubServiceTargetVerifier{verification: governance.ServiceTargetVerification{Status: governance.ServiceTargetMissing, Blocked: true, Message: "boom"}}
replayStore := &fakeJobStore{
activeUISettingsResult: governance.UISettings{ID: "settings-default", State: "active", ProductionKnowledgeBaseID: "kb-prod-1"},
revisionUISettingsResult: governance.UISettings{ID: "uis-kb", State: "validated", ProductionKnowledgeBaseID: "kb-prod-1"},
activatedUISettingsResult: governance.UISettings{ID: "uis-kb", State: "active", ProductionKnowledgeBaseID: "kb-prod-1"},
}
replayHandler, err := NewHandler(Config{Store: replayStore, ServiceTargetVerifier: replayVerifier, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
replayed := postUISettings(t, replayHandler, "/api/v1/ui-settings/uis-kb/activate", `{}`, "op-activate-replay", roleAdmin)
if replayed.Code != http.StatusOK || len(replayVerifier.targets) != 0 {
t.Fatalf("unchanged activation must not re-verify: status=%d calls=%d body=%s", replayed.Code, len(replayVerifier.targets), replayed.Body.String())
}
}
// 没有配置核验器(开发 socket)时响应形状不变,不出现空的 verification 字段。
func TestServiceConfigurationSaveWithoutVerifierOmitsVerification(t *testing.T) {
now := time.Date(2026, 9, 18, 9, 0, 0, 0, time.UTC)
store := &fakeJobStore{candidateUISettingsResult: governance.UISettings{ID: "uis-candidate", State: "validated", CreatedAt: now}}
handler, err := NewHandler(Config{Store: store, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
response := postUISettings(t, handler, "/api/v1/ui-settings/candidates", serviceCandidateBody, "op-service-no-verifier", roleAdmin)
if response.Code != http.StatusCreated || strings.Contains(response.Body.String(), "verification") {
t.Fatalf("unverified save status=%d body=%s", response.Code, response.Body.String())
}
}