- 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
211 lines
7.9 KiB
Go
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())
|
|
}
|
|
}
|