Files
Agent-System/internal/server/intent_plan_handlers_test.go
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- FIND-SEC-01: GetUserContext requires session ownership (handler 403 +
  SQL/memory owner filter); in-memory HTTP + isolated-PG regressions
- FIND-ARCH-01: archtest fails closed on untracked table writes (+noise
  exemption) and registers 50 existing writer tables in table_owners
- FIND-SEC-02: Authorizer without pool denies by default; explicit
  NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition
- FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact
  basename allowlist match; prefix-spoof and no-shell-eval tests
- FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed
  hard-refuses validated_only steps (new error codes + HTTP mapping)
- FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
2026-09-26 02:00:14 +00:00

211 lines
7.9 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
)
func u5Server(t *testing.T) (*Server, *intentflow.Plans, *intentflow.FakeActionOwner, string, int64) {
t.Helper()
cfg := &config.Config{
DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "dev",
ContextDbHost: "127.0.0.1", AdminUsername: "admin", AdminPassword: "admin-secret-password-123",
}
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
owner := intentflow.NewFakeActionOwner()
plans := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{Owner: owner})
srv.setOverrideIntentPlans(plans)
token, userID := u5Login(t, srv)
srv.planAccessFn = func(ctx context.Context, projectID, userID int64) error {
if projectID == 99 {
return authz.ErrForbidden
}
return nil
}
srv.planSessionOwnerFn = func(ctx context.Context, sessionID string) (int64, error) {
switch sessionID {
case "sess-u5", "sess-other":
return userID, nil
case "sess-foreign":
return userID + 99, nil
default:
return 0, intentflow.ErrPlanNotFoundOrHidden
}
}
return srv, plans, owner, token, userID
}
func u5Login(t *testing.T, srv *Server) (string, int64) {
t.Helper()
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin-secret-password-123"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
var sess auth.SessionInfo
if err := json.NewDecoder(rec.Body).Decode(&sess); err != nil || sess.Token == "" || sess.User == nil {
t.Fatalf("login decode: %v body=%s", err, rec.Body.String())
}
return sess.Token, sess.User.ID
}
func u5Do(srv *Server, method, path, token string, body any) *httptest.ResponseRecorder {
var r *http.Request
if body != nil {
raw, _ := json.Marshal(body)
r = httptest.NewRequest(method, path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, path, nil)
}
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, r)
return rec
}
func u5Create(t *testing.T, plans *intentflow.Plans, userID int64, projectID int64, sessionID, turn string) *intentflow.PlanView {
t.Helper()
view, err := plans.CreatePlan(context.Background(), intentflow.CreateCommand{
Principal: intentflow.Principal{UserID: userID, Environment: "dev", ProjectID: projectID, SessionID: sessionID},
ClientTurnID: turn,
RequestDigest: turn + "-digest",
WritePlan: true,
SafeSummary: "query then generate",
// 本组用例验证 live 确认/回执语义;缺省 validated_only 不可 Confirm。
ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "q1"},
{ActionKey: "task.run", ActionDigest: "w1"},
},
})
if err != nil {
t.Fatal(err)
}
return view
}
func TestIntentPlanHTTP_Unauthenticated(t *testing.T) {
srv, _, _, _, _ := u5Server(t)
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d %s", rec.Code, rec.Body.String())
}
}
func TestIntentPlanHTTP_ActiveWithoutProjection(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-active")
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("active %d %s", rec.Code, rec.Body.String())
}
var out struct {
Plan *intentflow.PlanView `json:"plan"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Plan == nil || out.Plan.PlanID != created.PlanID {
t.Fatalf("want stored plan, got %s err=%v", rec.Body.String(), err)
}
}
func TestIntentPlanHTTP_ScopeNegatives(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-scope")
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil); rec.Code != http.StatusForbidden {
t.Fatalf("non-member want 403, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=7&session_id=sess-u5", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("cross-project want 404, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=42&session_id=sess-other", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("cross-session want 404, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-foreign", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("foreign session want 404, got %d %s", rec.Code, rec.Body.String())
}
}
func TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-dec")
path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5"
body := map[string]any{
"expected_revision": created.Revision,
"decision_nonce": "nonce-same",
"decision_digest": "digest-same",
"kind": "confirm",
"user_id": userID + 8,
"project_id": 99,
"session_id": "injected",
}
first := u5Do(srv, http.MethodPost, path, token, body)
if first.Code != http.StatusOK {
t.Fatalf("confirm %d %s", first.Code, first.Body.String())
}
again := u5Do(srv, http.MethodPost, path, token, body)
if again.Code != http.StatusOK {
t.Fatalf("replay %d %s", again.Code, again.Body.String())
}
var a, b struct {
Plan *intentflow.PlanView `json:"plan"`
}
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if a.Plan == nil || b.Plan == nil || a.Plan.Revision != b.Plan.Revision {
t.Fatalf("same nonce must share receipt a=%+v b=%+v", a.Plan, b.Plan)
}
if a.Plan.Status != intentflow.PlanExecuting && a.Plan.Status != intentflow.PlanCompleted && a.Plan.Status != intentflow.PlanPartiallyCompleted {
t.Fatalf("confirm+dispatch status = %s", a.Plan.Status)
}
succeeded := 0
for _, s := range a.Plan.Steps {
if s.Status == intentflow.StepSucceeded {
succeeded++
}
}
if succeeded == 0 {
t.Fatalf("confirm must dispatch eligible steps, plan=%+v", a.Plan)
}
}
func TestIntentPlanHTTP_StaleRevision(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-stale")
path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5"
rec := u5Do(srv, http.MethodPost, path, token, map[string]any{
"expected_revision": created.Revision + 3,
"decision_nonce": "n-stale",
"decision_digest": "d-stale",
"kind": "confirm",
})
if rec.Code != http.StatusConflict {
t.Fatalf("stale want 409, got %d %s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("PLAN_STALE_REVISION")) {
t.Fatalf("want stale code, got %s", rec.Body.String())
}
}
func TestIntentPlanHTTP_AdminDoesNotBypassMembership(t *testing.T) {
srv, _, _, token, _ := u5Server(t)
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("admin must not bypass membership, got %d %s", rec.Code, rec.Body.String())
}
}