internal/adminapi/task_approval_handler_test.go
Rama main · réplica de lectura del repositorio original.
package adminapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/yeomyeonggeori/blueclaw/internal/agentruntime"
"github.com/yeomyeonggeori/blueclaw/internal/identity"
"github.com/yeomyeonggeori/blueclaw/internal/policy"
"github.com/yeomyeonggeori/blueclaw/internal/task"
"github.com/yeomyeonggeori/blueprotocol/agentcontract"
"github.com/yeomyeonggeori/blueprotocol/agentcontract/harnesstest"
"github.com/yeomyeonggeori/blueprotocol/holdrecord"
)
func approvalTestTaskRunService(t *testing.T) (*task.TaskRunService, *task.TaskEventService) {
t.Helper()
taskEventService := task.NewTaskEventService()
return task.NewTaskRunService(taskEventService), taskEventService
}
const (
requesterEmail = "requester@example.com"
adminEmail = "admin@example.com"
)
func rosterOfARequesterAndAnAdmin() *identity.IdentityService {
return identity.NewIdentityService(policy.PolicyProjection{
PersonIDByEmail: map[string]string{requesterEmail: "person-1", adminEmail: "person-admin"},
})
}
func configuredApprovalHandler(taskRunService *task.TaskRunService) TaskApprovalHandler {
return TaskApprovalHandler{
TaskRunService: taskRunService,
TaskLauncher: agentruntime.NewTaskLauncher(nil, taskRunService, agentruntime.NewToolCatalogBuilder()),
IdentityService: rosterOfARequesterAndAnAdmin(),
}
}
func decisionBy(approverEmail string, taskRunID string, decision string) string {
return `{"taskRunID":"` + taskRunID + `","decision":"` + decision + `","approverEmail":"` + approverEmail + `"}`
}
func postApproval(t *testing.T, handler TaskApprovalHandler, body string) *httptest.ResponseRecorder {
t.Helper()
responseRecorder := httptest.NewRecorder()
handler.HandleApproveTaskRun(responseRecorder, httptest.NewRequest(http.MethodPost, "/admin/api/run/approve", bytes.NewBufferString(body)))
return responseRecorder
}
func TestApprovalRefusesATaskRunThatIsNotWaitingForApproval(t *testing.T) {
taskRunService, _ := approvalTestTaskRunService(t)
taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "캘린더 정리")
handler := configuredApprovalHandler(taskRunService)
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "approve"))
if responseRecorder.Code != http.StatusBadRequest {
t.Fatalf("expected a running task run to be unapprovable, got %d", responseRecorder.Code)
}
if !strings.Contains(responseRecorder.Body.String(), "waiting for approval") {
t.Fatalf("expected the refusal to say why, got %s", responseRecorder.Body.String())
}
}
func TestApprovalRefusesAnUnknownTaskRunAndAnUnknownDecision(t *testing.T) {
taskRunService, _ := approvalTestTaskRunService(t)
taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "캘린더 정리")
if _, errorValue := taskRunService.PauseTaskRun(taskRun.TaskRunID, task.TaskStatusWaitingApproval, "삭제할까요?"); errorValue != nil {
t.Fatalf("expected the run to pause: %v", errorValue)
}
handler := configuredApprovalHandler(taskRunService)
if responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, "missing", "approve")); responseRecorder.Code != http.StatusBadRequest {
t.Fatalf("expected an unknown task run to be refused, got %d", responseRecorder.Code)
}
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "looks good"))
if responseRecorder.Code != http.StatusBadRequest || !strings.Contains(responseRecorder.Body.String(), "reject") {
t.Fatalf("expected a free-text decision to be refused with the allowed set, got %d %s", responseRecorder.Code, responseRecorder.Body.String())
}
}
func TestApprovalRefusesWhenNoLauncherCanCarryTheDecision(t *testing.T) {
taskRunService, _ := approvalTestTaskRunService(t)
taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "캘린더 정리")
if _, errorValue := taskRunService.PauseTaskRun(taskRun.TaskRunID, task.TaskStatusWaitingApproval, "삭제할까요?"); errorValue != nil {
t.Fatalf("expected the run to pause: %v", errorValue)
}
handler := TaskApprovalHandler{TaskRunService: taskRunService, IdentityService: rosterOfARequesterAndAnAdmin()}
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "approve"))
if responseRecorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected an unconfigured approval path to refuse rather than silently accept, got %d", responseRecorder.Code)
}
for _, taskEvent := range taskRunService.ListTaskEvent(taskRun.TaskRunID) {
if taskEvent.Name == "approval.decided" {
t.Fatal("expected no decision to be recorded when the approval could not be carried out")
}
}
}
func TestApprovalDecisionMapsToTheApprovalSignalTheGateExpects(t *testing.T) {
for decision, expectedSignal := range map[string]string{"approve": "approve", "reject": "reject"} {
approvalSignal, errorValue := approvalSignalOf(decision)
if errorValue != nil {
t.Fatalf("expected %q to map: %v", decision, errorValue)
}
if string(approvalSignal) != expectedSignal {
t.Fatalf("expected %q to carry the %q signal, got %q", decision, expectedSignal, approvalSignal)
}
}
}
func TestApprovalRequestRejectsAMalformedBody(t *testing.T) {
taskRunService, _ := approvalTestTaskRunService(t)
handler := configuredApprovalHandler(taskRunService)
if responseRecorder := postApproval(t, handler, `not json`); responseRecorder.Code != http.StatusBadRequest {
t.Fatalf("expected a malformed body to be refused, got %d", responseRecorder.Code)
}
var decoded map[string]string
responseRecorder := postApproval(t, handler, `{}`)
if json.Unmarshal(responseRecorder.Body.Bytes(), &decoded) != nil || decoded["error"] == "" {
t.Fatalf("expected a JSON error body, got %s", responseRecorder.Body.String())
}
}
func TestApprovingAHeldCallDecidesTheHoldAndResumesTheRunWithoutCarryingTheCallOut(t *testing.T) {
taskRunService, _ := approvalTestTaskRunService(t)
harness := harnesstest.New(taskRunService)
handler := TaskApprovalHandler{
TaskRunService: taskRunService,
TaskLauncher: agentruntime.NewTaskLauncher(harness, taskRunService, agentruntime.NewToolCatalogBuilder()),
IdentityService: rosterOfARequesterAndAnAdmin(),
}
taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "일정 삭제")
holdrecord.Open(taskRunService, taskRun.TaskRunID, agentcontract.HeldCall{ToolName: "event_delete", ToolInput: json.RawMessage(`{"eventHint":"event-1"}`), Confirmation: "삭제할까요?"}, nil)
if _, errorValue := taskRunService.PauseTaskRun(taskRun.TaskRunID, agentcontract.TaskStatusWaitingApproval, "삭제할까요?"); errorValue != nil {
t.Fatal(errorValue)
}
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "approve"))
if responseRecorder.Code != http.StatusOK {
t.Fatalf("expected the approval to resume the run, got %d %s", responseRecorder.Code, responseRecorder.Body.String())
}
holds := holdrecord.Holds(taskRunService.ListTaskEvent(taskRun.TaskRunID))
if len(holds) != 1 || holds[0].State != holdrecord.StateApproved {
t.Fatalf("expected the hold approved and left for the harness's repeated call to spend, got %+v", holds)
}
request := harness.LastTurnRequest()
if !request.IsRuntimeRestartResume || request.ExistingTaskRunID != taskRun.TaskRunID || len(request.CarriedOutCalls) != 0 {
t.Fatalf("expected a restart resume of the held run that carries nothing out, got %+v", request)
}
}
type recordedLiveHolds struct {
isAwaited bool
answers []agentcontract.ApprovalSignal
}
func (holds *recordedLiveHolds) AnswerAwaitedHold(_ string, signal agentcontract.ApprovalSignal) (bool, error) {
holds.answers = append(holds.answers, signal)
return holds.isAwaited, nil
}
func heldRunWithAdminHandler(t *testing.T, liveHolds LiveHolds) (TaskApprovalHandler, *harnesstest.Harness, task.TaskRun, *task.TaskRunService) {
t.Helper()
taskRunService, _ := approvalTestTaskRunService(t)
harness := harnesstest.New(taskRunService)
handler := TaskApprovalHandler{
LiveHolds: liveHolds,
TaskRunService: taskRunService,
TaskLauncher: agentruntime.NewTaskLauncher(harness, taskRunService, agentruntime.NewToolCatalogBuilder()),
IdentityService: rosterOfARequesterAndAnAdmin(),
}
taskRun := taskRunService.CreateTaskRun("person-1", "conversation-1", "일정 삭제")
holdrecord.Open(taskRunService, taskRun.TaskRunID, agentcontract.HeldCall{ToolName: "event_delete", ToolInput: json.RawMessage(`{"eventHint":"event-1"}`), Confirmation: "삭제할까요?"}, nil)
if _, errorValue := taskRunService.PauseTaskRun(taskRun.TaskRunID, agentcontract.TaskStatusWaitingApproval, "삭제할까요?"); errorValue != nil {
t.Fatal(errorValue)
}
return handler, harness, taskRun, taskRunService
}
func TestAnApprovalWhileTheAskingTurnIsStillWaitingIsHandedToThatTurnAndNothingIsResumed(t *testing.T) {
liveHolds := &recordedLiveHolds{isAwaited: true}
handler, harness, taskRun, taskRunService := heldRunWithAdminHandler(t, liveHolds)
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "approve"))
if responseRecorder.Code != http.StatusOK || len(liveHolds.answers) != 1 || liveHolds.answers[0] != agentcontract.ApprovalSignalApprove {
t.Fatalf("expected the live waiter to be answered once, got %d %+v", responseRecorder.Code, liveHolds.answers)
}
if harness.RunTurnCallCount() != 0 {
t.Fatal("a second turn was launched beside the one still waiting on the hold")
}
if holds := holdrecord.Holds(taskRunService.ListTaskEvent(taskRun.TaskRunID)); len(holds) != 1 || holds[0].State != holdrecord.StatePending {
t.Fatalf("the waiting turn settles the hold itself, got %+v", holds)
}
}
func TestAnApprovalWithNoTurnWaitingOnTheHoldResumesTheRunAsARestartResume(t *testing.T) {
liveHolds := &recordedLiveHolds{isAwaited: false}
handler, harness, taskRun, _ := heldRunWithAdminHandler(t, liveHolds)
responseRecorder := postApproval(t, handler, decisionBy(requesterEmail, taskRun.TaskRunID, "approve"))
if responseRecorder.Code != http.StatusOK || !harness.LastTurnRequest().IsRuntimeRestartResume {
t.Fatalf("expected the run resumed as a restart resume, got %d %+v", responseRecorder.Code, harness.LastTurnRequest())
}
}
func TestOnlyThePersonWhoAskedMayDecideTheirHeldCall(t *testing.T) {
for _, testCase := range []struct {
name string
approverEmail string
}{
{"an admin who is not the requester", adminEmail},
{"nobody named", ""},
{"an address the roster does not know", "stranger@example.com"},
} {
t.Run(testCase.name, func(t *testing.T) {
liveHolds := &recordedLiveHolds{isAwaited: true}
handler, harness, taskRun, taskRunService := heldRunWithAdminHandler(t, liveHolds)
for _, decision := range []string{"approve", "reject"} {
responseRecorder := postApproval(t, handler, decisionBy(testCase.approverEmail, taskRun.TaskRunID, decision))
if responseRecorder.Code != http.StatusForbidden {
t.Fatalf("expected %s to be refused, got %d %s", decision, responseRecorder.Code, responseRecorder.Body.String())
}
}
if len(liveHolds.answers) != 0 || harness.RunTurnCallCount() != 0 {
t.Fatalf("the refused decision reached the run: answers %+v, turns %d", liveHolds.answers, harness.RunTurnCallCount())
}
if holds := holdrecord.Holds(taskRunService.ListTaskEvent(taskRun.TaskRunID)); holds[0].State != holdrecord.StatePending {
t.Fatalf("the hold was decided by someone other than its requester: %+v", holds)
}
})
}
}
func TestTheRequesterDecidesTheirHeldCallWhateverCaseTheirAddressIsWrittenIn(t *testing.T) {
liveHolds := &recordedLiveHolds{isAwaited: true}
handler, _, taskRun, _ := heldRunWithAdminHandler(t, liveHolds)
responseRecorder := postApproval(t, handler, decisionBy(" Requester@Example.com ", taskRun.TaskRunID, "reject"))
if responseRecorder.Code != http.StatusOK || len(liveHolds.answers) != 1 || liveHolds.answers[0] != agentcontract.ApprovalSignalReject {
t.Fatalf("expected the requester's reject to reach the waiting turn, got %d %+v", responseRecorder.Code, liveHolds.answers)
}
}