From 8202a7a679471eb47c96ef3572ff57e90566304e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 11:37:33 +0800 Subject: [PATCH] test(acp): handler unit tests (admin / user / field redaction / PATCH semantics) --- internal/acp/handler_test.go | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 internal/acp/handler_test.go diff --git a/internal/acp/handler_test.go b/internal/acp/handler_test.go new file mode 100644 index 0000000..7f77e40 --- /dev/null +++ b/internal/acp/handler_test.go @@ -0,0 +1,161 @@ +package acp_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" +) + +// fakeResolver is a stub middleware.SessionResolver that accepts the literal "valid" token. +type fakeResolver struct{ uid uuid.UUID } + +func (f fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + if token == "valid" { + return f.uid, true, nil + } + return uuid.Nil, false, nil +} + +// fakeAdminLookup is a stub AdminLookup keyed by user ID. +type fakeAdminLookup struct{ admin map[uuid.UUID]bool } + +func (f fakeAdminLookup) IsAdmin(_ context.Context, uid uuid.UUID) (bool, error) { + return f.admin[uid], nil +} + +// mountHandlerWithRepo lets a test share one fakeAgentKindRepo across multiple +// mounts (e.g. one as admin, one as non-admin) so the data set is consistent. +func mountHandlerWithRepo(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo *fakeAgentKindRepo) chi.Router { + t.Helper() + if repo == nil { + repo = newFakeAgentKindRepo() + } + enc := testEncryptor(t) + svc := acp.NewAgentKindService(repo, enc, &agentKindRecordingRecorder{}) + + r := chi.NewRouter() + h := acp.NewHandler( + svc, + fakeResolver{uid: callerUID}, + fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}}, + enc, + ) + h.Mount(r) + return r +} + +func doJSON(t *testing.T, r chi.Router, method, path, token string, body any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + var out map[string]any + if rr.Body.Len() > 0 { + _ = json.Unmarshal(rr.Body.Bytes(), &out) + } + return rr, out +} + +func TestHandler_AgentKind_Admin_CreateGetList(t *testing.T) { + t.Parallel() + uid := uuid.New() + r := mountHandlerWithRepo(t, uid, true, nil) + + // Create + rr, body := doJSON(t, r, "POST", "/api/v1/acp/agent-kinds", "valid", map[string]any{ + "name": "claude_code", "display_name": "Claude Code", "binary_path": "claude-code", + "args": []string{"--acp"}, "env": map[string]string{"K": "v"}, "enabled": true, + }) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id := body["id"].(string) + assert.Equal(t, "claude_code", body["name"]) + envKeys := body["env_keys"].([]any) + assert.Equal(t, []any{"K"}, envKeys) + + // Get + rr, body = doJSON(t, r, "GET", "/api/v1/acp/agent-kinds/"+id, "valid", nil) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "Claude Code", body["display_name"]) + + // List + rr, _ = doJSON(t, r, "GET", "/api/v1/acp/agent-kinds", "valid", nil) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandler_AgentKind_NonAdmin_CannotWrite_FieldsRedacted(t *testing.T) { + t.Parallel() + uid := uuid.New() + repo := newFakeAgentKindRepo() + + // Admin creates one kind via the handler so audit + repo state are consistent. + adminR := mountHandlerWithRepo(t, uid, true, repo) + rr, _ := doJSON(t, adminR, "POST", "/api/v1/acp/agent-kinds", "valid", map[string]any{ + "name": "claude_code", "display_name": "Claude Code", "binary_path": "claude-code", + "args": []string{"--acp"}, "env": map[string]string{"K": "v"}, "enabled": true, + }) + require.Equal(t, http.StatusCreated, rr.Code) + + // Same UID but treated as non-admin (fakeAdminLookup says false). + userR := mountHandlerWithRepo(t, uid, false, repo) + + // List as non-admin: returns AgentKindPublicDTO array (no binary_path / env_keys). + rr2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/api/v1/acp/agent-kinds", nil) + req2.Header.Set("Authorization", "Bearer valid") + userR.ServeHTTP(rr2, req2) + require.Equal(t, http.StatusOK, rr2.Code) + var arr []map[string]any + require.NoError(t, json.Unmarshal(rr2.Body.Bytes(), &arr)) + require.Len(t, arr, 1) + assert.Equal(t, "claude_code", arr[0]["name"]) + assert.NotContains(t, arr[0], "binary_path") + assert.NotContains(t, arr[0], "env_keys") + assert.NotContains(t, arr[0], "args") + + // Non-admin POST → 403 Forbidden from the service layer. + rr3, _ := doJSON(t, userR, "POST", "/api/v1/acp/agent-kinds", "valid", map[string]any{ + "name": "x", "display_name": "x", "binary_path": "x", + }) + assert.Equal(t, http.StatusForbidden, rr3.Code) +} + +func TestHandler_AgentKind_Update_PatchSemantics(t *testing.T) { + t.Parallel() + uid := uuid.New() + r := mountHandlerWithRepo(t, uid, true, nil) + + rr, body := doJSON(t, r, "POST", "/api/v1/acp/agent-kinds", "valid", map[string]any{ + "name": "k", "display_name": "K", "binary_path": "/x", + }) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id := body["id"].(string) + + // PATCH display_name only — other fields preserved. + rr, body = doJSON(t, r, "PATCH", "/api/v1/acp/agent-kinds/"+id, "valid", map[string]any{ + "display_name": "Renamed", + }) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, "Renamed", body["display_name"]) + assert.Equal(t, "/x", body["binary_path"]) + + // DELETE + rr, _ = doJSON(t, r, "DELETE", "/api/v1/acp/agent-kinds/"+id, "valid", nil) + assert.Equal(t, http.StatusNoContent, rr.Code) +}