Ctrl K
ComunidadGuías…

internal/adminapi/task_approval_handler_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	}
}