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