Merge PR #139: 支持按状态和判定来源筛选审批记录
合并 codex/approval-record-filters,解决与 #133 在 web/src/components/approval-records.tsx 的冲突: - 导入同时保留 InterceptApprovalFilter 与 InterceptPending。 - load() 采用 #133 的独立待审批队列(Promise.allSettled 拉取 /intercept/pending),并把 #139 的 filter 参数透传给历史分页调用; 筛选只作用于历史记录,待处理队列不受 filter 影响,保证跨页/被筛 记录仍可审批。 - decide() 保留 #133 的乐观更新(移除 pendingRows 对应项)叠加 #139 决策后重新拉取以对齐筛选后的计数与行。 验证:前端 tsc 通过、Biome 仅剩预存告警;go build/vet 通过; db TestInterceptApprovalFilters(8 子用例)与 server TestInterceptFilterHTTP 在本地 dev 库均通过。 Closes #139
This commit is contained in:
commit
13ce2ea6e3
8 files changed
+436
-45
No files matched your search
+29
-4
@@ -248,7 +248,12 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("page") == "" && q.Get("size") == "" {
|
||||
filter, err := interceptFilterParams(q)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
||||
items, err := pg.ListTaskIntercepts(taskID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -261,7 +266,7 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
page, size := interceptPageParams(q)
|
||||
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size)
|
||||
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size, filter)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -278,7 +283,12 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("page") == "" && q.Get("size") == "" {
|
||||
filter, err := interceptFilterParams(q)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
||||
items, err := pg.ListAllIntercepts(200)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -291,7 +301,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
page, size := interceptPageParams(q)
|
||||
items, total, err := pg.ListAllInterceptsPage(page, size)
|
||||
items, total, err := pg.ListAllInterceptsPage(page, size, filter)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -302,6 +312,21 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
|
||||
switch filter.Status {
|
||||
case "", "pending", "allowed", "denied", "timeout":
|
||||
default:
|
||||
return filter, fmt.Errorf("status 必须是 pending、allowed、denied 或 timeout")
|
||||
}
|
||||
switch filter.DecisionSource {
|
||||
case "", "model", "rule", "unknown":
|
||||
default:
|
||||
return filter, fmt.Errorf("decision_source 必须是 model、rule 或 unknown")
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func interceptPageParams(q url.Values) (int, int) {
|
||||
page := atoiDefault(q.Get("page"), 1)
|
||||
size := atoiDefault(q.Get("size"), 20)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestInterceptFilterHTTP(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||
s := &Server{m: m, jwtKey: []byte("approval-filter-test-key")}
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := "approval-http-filter"
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") })
|
||||
_, baseline, err := d.ListAllInterceptsPage(1, 20, db.InterceptApprovalFilter{Status: "denied", DecisionSource: "model"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []string{"pending", "allowed", "denied", "timeout"} {
|
||||
if _, err := d.CreateDecidedIntercept(0, 0, scope, "test", "Bash", []byte(`{}`), state, "[模型] fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := d.CreateDecidedIntercept(0, 0, scope+"-other", "test", "Bash", []byte(`{}`), "denied", "[模型] fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do := func(path string, auth bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if auth {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for _, base := range []string{"/api/intercept/history", "/api/intercept/task/" + scope} {
|
||||
if rec := do(base+"?status=denied&decision_source=model", false); rec.Code != 401 {
|
||||
t.Fatalf("unprotected list: %d", rec.Code)
|
||||
}
|
||||
for _, query := range []string{"?status=invalid", "?decision_source=invalid", "?page=1&status=denied%27+OR+1%3D1--", "?size=20&decision_source=MODEL"} {
|
||||
if rec := do(base+query, true); rec.Code != 400 {
|
||||
t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Filtering without explicit page parameters must still apply the filters.
|
||||
rec := do(base+"?status=denied&decision_source=model", true)
|
||||
var body struct {
|
||||
Items []db.InterceptApprovalRow `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
|
||||
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
want := baseline + 2
|
||||
if base != "/api/intercept/history" {
|
||||
want = 1
|
||||
}
|
||||
if body.Total != want || body.Page != 1 {
|
||||
t.Fatalf("filter total=%d want=%d page=%d", body.Total, want, body.Page)
|
||||
}
|
||||
for _, r := range body.Items {
|
||||
if r.Status != "denied" || r.DecisionSource != "model" {
|
||||
t.Fatalf("nonmatching item: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
total, rows, page int
|
||||
}{
|
||||
{"", 4, 4, 0},
|
||||
{"?page=2&size=2&decision_source=model", 4, 2, 2},
|
||||
{"?page=2&size=1&status=denied&decision_source=model", 1, 0, 2},
|
||||
{"?status=denied&decision_source=unknown", 0, 0, 1},
|
||||
{"?page=0&size=0&status=pending", 1, 1, 1},
|
||||
} {
|
||||
rec := do("/api/intercept/task/"+scope+tc.query, true)
|
||||
var body struct {
|
||||
Items []db.InterceptApprovalRow `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Total != tc.total || len(body.Items) != tc.rows || body.Page != tc.page {
|
||||
t.Fatalf("%s: %d %s", tc.query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user