Files
Agent-System-Backend/internal/server/operation_query_test.go
weichen 4030449f4b Report honest receipts when handlers err after side effects
Why: F10 - any handler error became a no-side-effect rejected receipt
and an audit-store failure dropped the generated operation ID, losing
the reconciliation handle and inviting unsafe retries.
Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown,
audit-failure-keeps-ID and confirmation-idempotency cases added.
Impact: unknown receipts now carry the trusted operation ID via the
U7A constructor; rejected stays ID-free; no auto-replay anywhere.
Refs: feature/agent-stack-audit-remediation-b1 U7B
2026-10-21 16:55:01 +00:00

97 lines
3.4 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
)
// TestCommandOperationLookupIsOwnerProjectScoped 覆盖 U7b/R7 的对账查询:
// unknown 回执给出的 operationID 能按认证 owner + project 查到落库记录;
// 跨 owner、错 project、缺 projectId 一律查不到或拒(fail-closed),任何
// "查不到"都不得被读成"未执行"。
func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
srv, st := newAuthTestServer(t)
srv.Bus = cmdbus.New(st, nil, time.Now)
srv.WireBuiltinCommands() // 生产装配点:总线拿到宿主侧对账查询端口
h := srv.Handler()
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "对账项目")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("alice", project.ID, "对账会话", "")
if err != nil {
t.Fatal(err)
}
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"},
func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil {
t.Fatal(err)
}
resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{
ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID,
})
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt)
}
operationID := resp.Receipt.OperationID
get := func(key, url string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := get(aliceKey, "/api/commands/operations/"+operationID+"?projectId="+project.ID)
if rec.Code != http.StatusOK {
t.Fatalf("owner lookup: %d %s", rec.Code, rec.Body.String())
}
var out struct {
Operation struct {
OperationID string `json:"OperationID"`
CommandID string `json:"CommandID"`
State string `json:"State"`
} `json:"operation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Operation.OperationID != operationID || out.Operation.CommandID != "demo.write" || out.Operation.State != string(agentcommand.StateUnknown) {
t.Fatalf("lookup projection wrong: %+v", out.Operation)
}
for _, tc := range []struct {
name, key, url string
want int
}{
{"cross owner", bobKey, "/api/commands/operations/" + operationID + "?projectId=" + project.ID, http.StatusNotFound},
{"wrong project", aliceKey, "/api/commands/operations/" + operationID + "?projectId=prj_other", http.StatusNotFound},
{"missing project", aliceKey, "/api/commands/operations/" + operationID, http.StatusBadRequest},
{"missing operation", aliceKey, "/api/commands/operations/op_absent?projectId=" + project.ID, http.StatusNotFound},
} {
if rec := get(tc.key, tc.url); rec.Code != tc.want {
t.Fatalf("%s: %d %s (want %d)", tc.name, rec.Code, rec.Body.String(), tc.want)
}
}
}