4b3aaff321
治理侧保存输入观测版本、自动复检及人工复核历史,输入变化失效、版本冲突和幂等重放不覆盖人工记录。批量质量数量使用完整筛选元数据,未采集原文不伪造快照,延迟旧扫描不回退新输入。 验证:全仓 make verify 退出码0,Go656及前端253通过;五包竞态、源/组织/质量隔离PG、真实只读源和隔离网页读回复核通过。默认91个可选集成跳过不计通过。未接AI模型,不写DobotAI源库,未推送、未合并、未部署。
172 lines
7.8 KiB
Go
172 lines
7.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"knowledge-governance/internal/dobotaiops"
|
|
"knowledge-governance/internal/qaopsquality"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type qualityFake struct {
|
|
actor string
|
|
calls int
|
|
fail bool
|
|
}
|
|
|
|
func (s *qualityFake) Observe(_ context.Context, records []dobotaiops.Record) (map[string]qaopsquality.View, error) {
|
|
s.calls++
|
|
if s.fail {
|
|
return nil, qaopsquality.ErrUnavailable
|
|
}
|
|
out := map[string]qaopsquality.View{}
|
|
for _, r := range records {
|
|
e, _ := qaopsquality.EvidenceFor(r)
|
|
out[r.ID] = qaopsquality.Project(e.Hash, 1, 0, qaopsquality.Check{InputEpoch: 1, InputHash: e.Hash, Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, nil)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *qualityFake) ObserveKeys(_ context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) {
|
|
s.calls++
|
|
out := map[string]qaopsquality.View{}
|
|
for _, key := range keys {
|
|
out[key.ID] = qaopsquality.Project(key.InputHash, 1, 0, qaopsquality.Check{Result: dobotaiops.CheckMarkerCoverage(key.Marker)}, nil)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *qualityFake) Save(_ context.Context, r dobotaiops.Record, q qaopsquality.SaveRequest, actor string) (qaopsquality.Receipt, error) {
|
|
s.actor = actor
|
|
v := qaopsquality.Project(q.InputHash, 1, 1, qaopsquality.Check{Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, &qaopsquality.Review{InputEpoch: 1, InputHash: q.InputHash, Fields: q.Fields, Reason: q.Reason, Reviewer: actor})
|
|
return qaopsquality.Receipt{View: v, SavedRevision: 1}, nil
|
|
}
|
|
func (s *qualityFake) Columns(_ context.Context, keys []dobotaiops.QualityKey) ([]string, error) {
|
|
return []string{"is_correct", "product_category"}, nil
|
|
}
|
|
func (s *qualityFake) History(context.Context, string, int64, int64) (qaopsquality.History, error) {
|
|
return qaopsquality.History{Reviews: []qaopsquality.Review{}, Checks: []qaopsquality.Check{}}, nil
|
|
}
|
|
func (s *qualityFake) Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error) {
|
|
return qaopsquality.Snapshot{}, qaopsquality.ErrNotFound
|
|
}
|
|
|
|
type qualitySource struct {
|
|
attributedReader
|
|
record dobotaiops.Record
|
|
tenant string
|
|
scopeCalls int
|
|
}
|
|
|
|
func (s *qualitySource) GetRecord(_ context.Context, tenant, id string) (dobotaiops.Record, error) {
|
|
s.tenant = tenant
|
|
return s.record, nil
|
|
}
|
|
func (s *qualitySource) ListRecords(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
|
|
return dobotaiops.RecordPage{Items: []dobotaiops.Record{s.record}, Columns: []string{"question"}, Total: 2, AsOf: time.Now()}, nil
|
|
}
|
|
func (s *qualitySource) ReadQualityScope(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) {
|
|
s.scopeCalls++
|
|
s.tenant = q.Window.TenantKey
|
|
second := s.record
|
|
second.ID = "second-turn"
|
|
keys := []dobotaiops.QualityKey{}
|
|
for _, r := range []dobotaiops.Record{s.record, second} {
|
|
e, _ := qaopsquality.EvidenceFor(r)
|
|
keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: e.Hash, Marker: e.Result.Verdict == "uncovered", Source: r.SourceCoverage})
|
|
}
|
|
return dobotaiops.QualityScope{Keys: keys, AsOf: time.Now()}, nil
|
|
}
|
|
func qualityTestHandler(t *testing.T) (http.Handler, *qualityFake, *qualitySource) {
|
|
t.Helper()
|
|
store := &qualityFake{}
|
|
source := &qualitySource{record: dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例", Answer: "暂无已发布"}}
|
|
auth := newFakeAuthStore()
|
|
auth.sessions["quality-admin"] = "usr-boss"
|
|
auth.sessions["quality-viewer"] = "usr-eye"
|
|
h, e := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: source, QAOpsTenantKey: "fixed", QAQuality: store, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }})
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
return h, store, source
|
|
}
|
|
func qualityRequest(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("X-Governance-Action", "qa-quality")
|
|
if token != "" {
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
func TestQAQualityDetailListAndWholeScopeCounts(t *testing.T) {
|
|
h, store, source := qualityTestHandler(t)
|
|
w := qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"quality"`) {
|
|
t.Fatal("persisted quality absent in detail", w.Code)
|
|
}
|
|
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", "quality-admin", "")
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"is_correct"`) {
|
|
t.Fatal("whole-filter quality columns absent")
|
|
}
|
|
w = qualityRequest(h, "POST", "/api/v1/qa-ops/quality/query", "quality-admin", `{"start":"2026-10-14T00:00:00Z","end":"2026-10-15T00:00:00Z","keyword":""}`)
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"unreviewed":2`) || source.scopeCalls != 1 || source.tenant != "fixed" || store.calls < 2 {
|
|
t.Fatal("quality summary estimated from page instead of scope", w.Code, w.Body.String())
|
|
}
|
|
store.fail = true
|
|
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
|
|
if w.Code != 503 || !strings.Contains(w.Body.String(), "quality_unavailable") {
|
|
t.Fatal("quality storage failure presented as empty success")
|
|
}
|
|
}
|
|
func TestQAQualityReviewUsesSessionActorAndStrictReadOnlySourceBoundary(t *testing.T) {
|
|
h, store, source := qualityTestHandler(t)
|
|
e, _ := qaopsquality.EvidenceFor(source.record)
|
|
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "uncovered", Correctness: "pending"}, Reason: "人工核对"}
|
|
raw, _ := json.Marshal(q)
|
|
path := "/api/v1/qa-ops/records/fixture-turn/reviews"
|
|
w := qualityRequest(h, "POST", path, "quality-admin", string(raw))
|
|
if w.Code != 200 || store.actor != "boss" || source.tenant != "fixed" {
|
|
t.Fatal("manual review failed or actor came from request", w.Code, store.actor)
|
|
}
|
|
for _, tc := range []struct {
|
|
token, body string
|
|
status int
|
|
}{{"", string(raw), 401}, {"quality-viewer", string(raw), 403}, {"quality-admin", strings.TrimSuffix(string(raw), "}") + `,"reviewer":"forged"}`, 400}, {"quality-admin", `null`, 400}, {"quality-admin", string(raw) + `{}`, 400}, {"quality-admin", `{"nonce":"a","nonce":"b"}`, 400}} {
|
|
w = qualityRequest(h, "POST", path, tc.token, tc.body)
|
|
if w.Code != tc.status {
|
|
t.Fatal("review boundary failed", w.Code, tc.status)
|
|
}
|
|
}
|
|
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "quality-admin"})
|
|
r.Header.Set("X-Governance-Action", "qa-quality")
|
|
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != 403 {
|
|
t.Fatal("cross-site review accepted")
|
|
}
|
|
w = qualityRequest(h, "GET", path+"/invalid", "quality-admin", "")
|
|
if w.Code != 404 {
|
|
t.Fatal("unknown endpoint silently accepted")
|
|
}
|
|
}
|
|
|
|
func TestQAQualityReviewRejectsMissingRevisionAndNestedNull(t *testing.T) {
|
|
h, _, source := qualityTestHandler(t)
|
|
e, _ := qaopsquality.EvidenceFor(source.record)
|
|
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "pending", Correctness: "pending"}, Reason: "示例复核"}
|
|
raw, _ := json.Marshal(q)
|
|
for _, body := range []string{strings.Replace(string(raw), `"base_revision":0,`, "", 1), strings.Replace(string(raw), `"base_revision":0`, `"base_revision":null`, 1), strings.Replace(string(raw), `"product_category":""`, `"product_category":null`, 1)} {
|
|
w := qualityRequest(h, "POST", "/api/v1/qa-ops/records/fixture-turn/reviews", "quality-admin", body)
|
|
if w.Code != 400 {
|
|
t.Fatal("missing/null version or field silently became a default", w.Code)
|
|
}
|
|
}
|
|
}
|