222 lines
9.5 KiB
Go
222 lines
9.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"knowledge-governance/internal/dobotaiops"
|
|
"knowledge-governance/internal/qaopsorg"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type monthlyReaderSpy struct {
|
|
attributedReader
|
|
query dobotaiops.RecordQuery
|
|
calls int
|
|
}
|
|
|
|
func (s *monthlyReaderSpy) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
|
|
s.calls++
|
|
s.query = q
|
|
return s.attributedReader.ReadUsageGroups(ctx, q)
|
|
}
|
|
func monthlyRequest(h http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
u, _ := url.Parse(path)
|
|
body := map[string]any{}
|
|
for k, v := range u.Query() {
|
|
if len(v) != 1 {
|
|
body[k] = v
|
|
continue
|
|
}
|
|
if k == "page" || k == "limit" {
|
|
if n, e := strconv.Atoi(v[0]); e == nil {
|
|
body[k] = n
|
|
} else {
|
|
body[k] = v[0]
|
|
}
|
|
} else {
|
|
body[k] = v[0]
|
|
}
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
r := httptest.NewRequest(http.MethodPost, u.Path, bytes.NewReader(raw))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("X-Governance-Action", "qa-usage-read")
|
|
if token != "" {
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func monthlyHandler(t *testing.T) (http.Handler, *attributionOrgSpy, *monthlyReaderSpy) {
|
|
t.Helper()
|
|
d := qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例根"}, {ID: "child", ParentID: "root", Name: "子部门"}}, People: []qaopsorg.Person{{ID: "fixture-private-staff", Name: "同名示例", UnionID: "fixture-private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", Name: "同名示例", DepartmentIDs: []string{"child"}}, {ID: "private-cross", Name: "跨区域示例", DepartmentIDs: []string{"root", "child"}}, {ID: "private-unnamed", DepartmentIDs: []string{"child"}}}}
|
|
now := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
|
|
p, err := qaopsorg.BuildAttribution(qaopsorg.State{State: "ready", DirectoryID: "monthly-directory", DirectorySource: "weekly_report_live", DirectorySyncedAt: &now, RevisionID: "monthly-regions", Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "国内工业"}, {DepartmentID: "child", Region: "APAC"}}}, d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
org := &attributionOrgSpy{projection: p}
|
|
reader := &monthlyReaderSpy{}
|
|
auth := newFakeAuthStore()
|
|
auth.sessions["attribution-admin"] = "usr-boss"
|
|
auth.sessions["attribution-viewer"] = "usr-eye"
|
|
h, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: reader, QAOpsTenantKey: "fixed", QAOrg: org, Now: func() time.Time { return now }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h, org, reader
|
|
}
|
|
func TestQAMonthlyPeopleIncludesZeroUseAndKeepsUnresolvedEvents(t *testing.T) {
|
|
h, _, reader := monthlyHandler(t)
|
|
path := "/api/v1/qa-ops/usage/people?month=2026-10&limit=2"
|
|
w := monthlyRequest(h, path, "attribution-admin")
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
var result struct {
|
|
Data struct {
|
|
Month string
|
|
Timezone string
|
|
Partial bool
|
|
Questions int64
|
|
LinkedQuestions int64 `json:"linked_questions"`
|
|
UnresolvedQuestions int64 `json:"unresolved_questions"`
|
|
RosterPeople int `json:"roster_people"`
|
|
UsedPeople int `json:"used_people"`
|
|
ZeroPeople int `json:"zero_people"`
|
|
MissingNames int `json:"missing_names"`
|
|
Total int
|
|
HasMore bool `json:"has_more"`
|
|
Rows []struct {
|
|
Key string
|
|
Name string
|
|
Questions int64
|
|
State string
|
|
}
|
|
}
|
|
}
|
|
if json.Unmarshal(w.Body.Bytes(), &result) != nil {
|
|
t.Fatal("invalid monthly JSON")
|
|
}
|
|
v := result.Data
|
|
if v.Month != "2026-10" || v.Timezone != "Asia/Shanghai" || !v.Partial || v.Questions != 7 || v.LinkedQuestions != 5 || v.UnresolvedQuestions != 2 || v.RosterPeople != 4 || v.UsedPeople != 1 || v.ZeroPeople != 3 || v.MissingNames != 1 || v.Total != 4 || len(v.Rows) != 2 || !v.HasMore {
|
|
t.Fatal("monthly roster, zero count or alias dedup wrong", w.Body.String())
|
|
}
|
|
if reader.query.Window.TenantKey != "fixed" || reader.query.Window.Start.Format(time.RFC3339) != "2026-10-01T00:00:00+08:00" || reader.query.Window.End.Format(time.RFC3339) != "2026-10-17T16:00:00+08:00" {
|
|
t.Fatal("month was not normalized to the named timezone")
|
|
}
|
|
if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "performance") || strings.Contains(w.Body.String(), "staff_id") {
|
|
t.Fatal("raw identities or unapproved verdict exposed")
|
|
}
|
|
page2 := monthlyRequest(h, path+"&page=2&directory_id=monthly-directory&revision_id=monthly-regions", "attribution-admin")
|
|
if page2.Code != 200 {
|
|
t.Fatal(page2.Body.String())
|
|
}
|
|
for _, extra := range []string{"&activity=zero", "®ion=cross_region", "&department_id=child", "&search=" + url.QueryEscape("子部门")} {
|
|
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+extra, "attribution-admin")
|
|
if r.Code != 200 {
|
|
t.Fatal("valid monthly filter failed", r.Body.String())
|
|
}
|
|
}
|
|
}
|
|
func TestQAMonthlyScopeAndVersionFailClosed(t *testing.T) {
|
|
h, org, reader := monthlyHandler(t)
|
|
base := "/api/v1/qa-ops/usage/people?month=2026-10"
|
|
for _, tc := range []struct {
|
|
suffix, token string
|
|
status int
|
|
}{{"", "", 401}, {"", "attribution-viewer", 403}, {"&tenant=forged", "attribution-admin", 400}, {"&threshold=5", "attribution-admin", 400}, {"&month=duplicate", "attribution-admin", 400}, {"&page=0", "attribution-admin", 400}, {"&limit=101", "attribution-admin", 400}, {"&activity=under5", "attribution-admin", 400}, {"®ion=invalid", "attribution-admin", 400}, {"&department_id=invalid", "attribution-admin", 400}, {"&page=2", "attribution-admin", 400}, {"&page=2&directory_id=changed&revision_id=monthly-regions", "attribution-admin", 409}, {"&cutoff=2026-11-01T00:00:00Z", "attribution-admin", 400}} {
|
|
w := monthlyRequest(h, base+tc.suffix, tc.token)
|
|
if w.Code != tc.status {
|
|
t.Fatalf("monthly %s: %d want %d", tc.suffix, w.Code, tc.status)
|
|
}
|
|
}
|
|
for _, month := range []string{"2026-1", " 2026-10", "2026-13", "0000-01", "2026-11"} {
|
|
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month="+url.QueryEscape(month), "attribution-admin")
|
|
if r.Code != 400 {
|
|
t.Fatal("invalid/future month accepted")
|
|
}
|
|
}
|
|
if reader.calls != 0 {
|
|
t.Fatal("invalid or unauthorized monthly request reached source")
|
|
}
|
|
org.projection, _ = qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
|
|
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 409 {
|
|
t.Fatal("no roster presented as zero-use list")
|
|
}
|
|
org.err = qaopsorg.ErrUnavailable
|
|
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 503 {
|
|
t.Fatal("store failure hidden as empty")
|
|
}
|
|
}
|
|
|
|
func TestQAMonthlyPersonFiltersDoNotGuessNamesOrDuplicateDescendantMembers(t *testing.T) {
|
|
h, _, _ := monthlyHandler(t)
|
|
for _, tc := range []struct {
|
|
filter string
|
|
total int
|
|
}{{"&activity=used", 1}, {"&activity=zero", 3}, {"®ion=cross_region", 1}, {"®ion=APAC", 2}, {"&department_id=child", 3}, {"&department_id=root", 4}, {"&search=" + url.QueryEscape("同名示例"), 2}, {"&search=" + url.QueryEscape("子部门"), 3}, {"&search=%25_", 0}} {
|
|
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+tc.filter, "attribution-admin")
|
|
var payload struct{ Data monthlyPeople }
|
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &payload) != nil || payload.Data.Total != tc.total || payload.Data.Questions != 7 {
|
|
t.Fatal("person-scope filter did not preserve source totals or person dedup", tc.filter)
|
|
}
|
|
for _, r := range payload.Data.Rows {
|
|
if tc.filter == "&activity=zero" && r.Questions != 0 {
|
|
t.Fatal("used member labeled zero")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func TestQAMonthlyReadBodyRejectsDuplicateNullTrailingAndOversizedInput(t *testing.T) {
|
|
h, _, reader := monthlyHandler(t)
|
|
for _, body := range []string{`{"month":"2026-10","month":"2026-10"}`, `{"month":"2026-10","search":null}`, `{"month":"2026-10","page":"1"}`, `{"month":"2026-10"}{}`, `[]`, `{"month":"2026-10","search":"` + strings.Repeat("x", 9000) + `"}`} {
|
|
r := httptest.NewRequest("POST", "/api/v1/qa-ops/usage/people", strings.NewReader(body))
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
|
|
r.Header.Set("X-Governance-Action", "qa-usage-read")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != 400 {
|
|
t.Fatal("invalid read body reached source", w.Code)
|
|
}
|
|
}
|
|
for _, method := range []string{"POST", "GET"} {
|
|
r := httptest.NewRequest(method, "/api/v1/qa-ops/usage/people?search=sensitive-example", strings.NewReader(`{"month":"2026-10"}`))
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != 400 && w.Code != 405 {
|
|
t.Fatal("URL-based name query accepted")
|
|
}
|
|
}
|
|
if reader.calls != 0 {
|
|
t.Fatal("invalid body read production source")
|
|
}
|
|
}
|
|
|
|
func TestQAMonthlyPastLeapMonthUsesClosedCalendarWindow(t *testing.T) {
|
|
h, _, reader := monthlyHandler(t)
|
|
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2024-02", "attribution-admin")
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Body.String())
|
|
}
|
|
if reader.query.Window.End.Format(time.RFC3339) != "2024-03-01T00:00:00+08:00" || reader.query.Window.End.Sub(reader.query.Window.Start) != 29*24*time.Hour {
|
|
t.Fatal("leap month boundary incorrect")
|
|
}
|
|
var raw struct{ Data struct{ Partial bool } }
|
|
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
|
if raw.Data.Partial {
|
|
t.Fatal("past natural month mislabeled as current progress")
|
|
}
|
|
}
|