e75c8b37c8
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
690 lines
23 KiB
Go
690 lines
23 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// splitTestServerHostPort turns an httptest listener address into the
|
|
// (host, port) pair the New*Client constructors take.
|
|
func splitTestServerHostPort(t *testing.T, srv *httptest.Server) (string, int) {
|
|
t.Helper()
|
|
host, port, err := net.SplitHostPort(srv.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("split server address: %v", err)
|
|
}
|
|
var portNumber int
|
|
if _, err := fmt.Sscanf(port, "%d", &portNumber); err != nil {
|
|
t.Fatalf("parse server port: %v", err)
|
|
}
|
|
return host, portNumber
|
|
}
|
|
|
|
// newTestControlClient builds a ControlClient pointed at srv, going through the
|
|
// real constructor so base-URL assembly and the auth transport are exercised.
|
|
func newTestControlClient(t *testing.T, srv *httptest.Server, opts ...ControlClientOption) *ControlClient {
|
|
t.Helper()
|
|
host, port := splitTestServerHostPort(t, srv)
|
|
return NewControlClient(host, port, newTestLogger(), opts...)
|
|
}
|
|
|
|
func TestNewControlClient_BuildsBaseURLFromHostAndPort(t *testing.T) {
|
|
c := NewControlClient("agentctl.internal", 7777, newTestLogger())
|
|
if c.baseURL != "http://agentctl.internal:7777" {
|
|
t.Errorf("baseURL = %q, want http://agentctl.internal:7777", c.baseURL)
|
|
}
|
|
if c.httpClient.Timeout != 30*time.Second {
|
|
t.Errorf("timeout = %v, want 30s", c.httpClient.Timeout)
|
|
}
|
|
if c.httpClient.Transport != nil {
|
|
t.Error("transport installed without an auth token")
|
|
}
|
|
if c.AuthToken() != "" {
|
|
t.Errorf("AuthToken = %q, want empty", c.AuthToken())
|
|
}
|
|
}
|
|
|
|
func TestControlClient_SetAuthTokenIsSentOnSubsequentRequests(t *testing.T) {
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = append(seen, r.Header.Get("Authorization"))
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newTestControlClient(t, srv)
|
|
if err := c.Health(context.Background()); err != nil {
|
|
t.Fatalf("Health before token: %v", err)
|
|
}
|
|
|
|
c.SetAuthToken("rotated-token")
|
|
if c.AuthToken() != "rotated-token" {
|
|
t.Errorf("AuthToken = %q, want rotated-token", c.AuthToken())
|
|
}
|
|
if err := c.Health(context.Background()); err != nil {
|
|
t.Fatalf("Health after token: %v", err)
|
|
}
|
|
|
|
if len(seen) != 2 {
|
|
t.Fatalf("saw %d requests, want 2", len(seen))
|
|
}
|
|
if seen[0] != "" {
|
|
t.Errorf("first Authorization = %q, want empty before SetAuthToken", seen[0])
|
|
}
|
|
if seen[1] != "Bearer rotated-token" {
|
|
t.Errorf("second Authorization = %q, want Bearer rotated-token", seen[1])
|
|
}
|
|
}
|
|
|
|
func TestControlClient_Health(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
wantErr string
|
|
}{
|
|
{"healthy", http.StatusOK, ""},
|
|
{"unavailable", http.StatusServiceUnavailable, "health check failed: 503"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var gotPath, gotMethod string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath, gotMethod = r.URL.Path, r.Method
|
|
w.WriteHeader(tc.status)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
err := newTestControlClient(t, srv).Health(context.Background())
|
|
if gotMethod != http.MethodGet || gotPath != "/health" {
|
|
t.Errorf("request = %s %s, want GET /health", gotMethod, gotPath)
|
|
}
|
|
if tc.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("Health: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestControlClient_HealthHonoursContextCancellation(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
err := newTestControlClient(t, srv).Health(ctx)
|
|
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
|
t.Fatalf("error = %v, want context canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestHandshake_PostsNonceAndAdoptsReturnedToken(t *testing.T) {
|
|
var gotPath, gotMethod, gotContentType string
|
|
var gotBody map[string]string
|
|
var secondAuth string
|
|
requests := 0
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
if requests == 1 {
|
|
gotPath, gotMethod = r.URL.Path, r.Method
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"token":"issued-token"}`))
|
|
return
|
|
}
|
|
secondAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newTestControlClient(t, srv)
|
|
token, err := c.Handshake(context.Background(), "one-time-nonce")
|
|
if err != nil {
|
|
t.Fatalf("Handshake: %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodPost || gotPath != "/auth/handshake" {
|
|
t.Errorf("request = %s %s, want POST /auth/handshake", gotMethod, gotPath)
|
|
}
|
|
if gotContentType != "application/json" {
|
|
t.Errorf("content-type = %q, want application/json", gotContentType)
|
|
}
|
|
if gotBody["nonce"] != "one-time-nonce" {
|
|
t.Errorf("nonce = %q, want one-time-nonce", gotBody["nonce"])
|
|
}
|
|
if token != "issued-token" {
|
|
t.Errorf("token = %q, want issued-token", token)
|
|
}
|
|
if c.AuthToken() != "issued-token" {
|
|
t.Errorf("AuthToken = %q, want the handshake to adopt the token", c.AuthToken())
|
|
}
|
|
|
|
// The adopted token must actually reach the wire, not just the field.
|
|
if err := c.Health(context.Background()); err != nil {
|
|
t.Fatalf("Health after handshake: %v", err)
|
|
}
|
|
if secondAuth != "Bearer issued-token" {
|
|
t.Errorf("post-handshake Authorization = %q, want Bearer issued-token", secondAuth)
|
|
}
|
|
}
|
|
|
|
func TestHandshake_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "rejected with structured error",
|
|
status: http.StatusUnauthorized,
|
|
body: `{"error":"nonce already consumed"}`,
|
|
wantErr: "handshake rejected: nonce already consumed (status 401)",
|
|
},
|
|
{
|
|
name: "rejected without structured error",
|
|
status: http.StatusForbidden,
|
|
body: `not json`,
|
|
wantErr: "handshake failed: status 403",
|
|
},
|
|
{
|
|
name: "empty token",
|
|
status: http.StatusOK,
|
|
body: `{"token":""}`,
|
|
wantErr: "handshake returned empty token",
|
|
},
|
|
{
|
|
name: "malformed success body",
|
|
status: http.StatusOK,
|
|
body: `{"token":`,
|
|
wantErr: "failed to decode handshake response",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newTestControlClient(t, srv)
|
|
token, err := c.Handshake(context.Background(), "n")
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if token != "" {
|
|
t.Errorf("token = %q, want empty on failure", token)
|
|
}
|
|
if c.AuthToken() != "" {
|
|
t.Errorf("AuthToken = %q, want no token adopted on failure", c.AuthToken())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSubprocessAdmission_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"non-OK status", http.StatusInternalServerError, `{}`, "failed to get subprocess admission: status 500"},
|
|
{"malformed body", http.StatusOK, `{"pool":`, "failed to decode subprocess admission"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
snapshot, err := newTestControlClient(t, srv).SubprocessAdmission(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if snapshot.Pool != "" || snapshot.Capacity != 0 {
|
|
t.Errorf("snapshot = %+v, want the zero value on failure", snapshot)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateInstance_MarshalsFullRequestAndDecodesIDAndPort(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusCreated, `{"id":"inst-1","port":41234}`))
|
|
|
|
autoApprove := true
|
|
req := &CreateInstanceRequest{
|
|
ID: "inst-1",
|
|
WorkspacePath: "/workspace/task-1",
|
|
AgentCommand: "claude-code acp",
|
|
Protocol: "acp",
|
|
AgentType: "codex",
|
|
WorkspaceFlag: "--workspace-root",
|
|
Env: map[string]string{"FOO": "bar"},
|
|
AutoStart: true,
|
|
AutoApprovePermissions: &autoApprove,
|
|
McpServers: []McpServerConfig{{
|
|
Name: "kandev",
|
|
URL: "http://127.0.0.1:9/mcp",
|
|
Type: "http",
|
|
Command: "kandev-mcp",
|
|
Args: []string{"--stdio"},
|
|
Env: map[string]string{"TOKEN": "t"},
|
|
Headers: map[string]string{"X-Trace": "1"},
|
|
}},
|
|
SessionID: "sess-1",
|
|
TaskID: "task-1",
|
|
DisableAskQuestion: true,
|
|
AssumeMcpSse: true,
|
|
AssumeMcpHttp: true,
|
|
McpMode: "task",
|
|
McpProviders: []string{"github"},
|
|
McpProfile: &mcpprofile.Context{},
|
|
RequiresProcessKill: true,
|
|
StripEnv: []string{"ANTHROPIC_API_KEY"},
|
|
BaseBranches: map[string]string{"": "origin/main", "web": "origin/develop"},
|
|
RemoteContributions: map[string]models.RemoteContribution{"": {}},
|
|
WorkspaceSourceRoots: []string{"/sources/a"},
|
|
}
|
|
|
|
resp, err := newTestControlClient(t, srv).CreateInstance(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("CreateInstance: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodPost || got.Path != "/api/v1/instances" {
|
|
t.Errorf("request = %s %s, want POST /api/v1/instances", got.Method, got.Path)
|
|
}
|
|
if got.ContentType != "application/json" {
|
|
t.Errorf("content-type = %q, want application/json", got.ContentType)
|
|
}
|
|
|
|
var sent CreateInstanceRequest
|
|
if err := json.Unmarshal(got.Body, &sent); err != nil {
|
|
t.Fatalf("decode sent body: %v", err)
|
|
}
|
|
if sent.ID != "inst-1" || sent.WorkspacePath != "/workspace/task-1" {
|
|
t.Errorf("sent id/workspace = %q / %q", sent.ID, sent.WorkspacePath)
|
|
}
|
|
if sent.AgentCommand != "claude-code acp" || sent.Protocol != "acp" || sent.AgentType != "codex" {
|
|
t.Errorf("sent command/protocol/type = %q / %q / %q", sent.AgentCommand, sent.Protocol, sent.AgentType)
|
|
}
|
|
if sent.WorkspaceFlag != "--workspace-root" || sent.Env["FOO"] != "bar" || !sent.AutoStart {
|
|
t.Errorf("sent flag/env/autostart = %q / %v / %v", sent.WorkspaceFlag, sent.Env, sent.AutoStart)
|
|
}
|
|
if sent.AutoApprovePermissions == nil || !*sent.AutoApprovePermissions {
|
|
t.Errorf("sent AutoApprovePermissions = %v, want pointer to true", sent.AutoApprovePermissions)
|
|
}
|
|
if len(sent.McpServers) != 1 {
|
|
t.Fatalf("sent McpServers = %d, want 1", len(sent.McpServers))
|
|
}
|
|
mcp := sent.McpServers[0]
|
|
if mcp.Name != "kandev" || mcp.URL != "http://127.0.0.1:9/mcp" || mcp.Type != "http" {
|
|
t.Errorf("sent mcp identity = %+v", mcp)
|
|
}
|
|
if mcp.Command != "kandev-mcp" || len(mcp.Args) != 1 || mcp.Args[0] != "--stdio" {
|
|
t.Errorf("sent mcp command/args = %q / %v", mcp.Command, mcp.Args)
|
|
}
|
|
if mcp.Env["TOKEN"] != "t" || mcp.Headers["X-Trace"] != "1" {
|
|
t.Errorf("sent mcp env/headers = %v / %v", mcp.Env, mcp.Headers)
|
|
}
|
|
if sent.SessionID != "sess-1" || sent.TaskID != "task-1" {
|
|
t.Errorf("sent session/task = %q / %q", sent.SessionID, sent.TaskID)
|
|
}
|
|
if !sent.DisableAskQuestion || !sent.AssumeMcpSse || !sent.AssumeMcpHttp {
|
|
t.Errorf("sent mcp capability flags = %v / %v / %v",
|
|
sent.DisableAskQuestion, sent.AssumeMcpSse, sent.AssumeMcpHttp)
|
|
}
|
|
if sent.McpMode != "task" || len(sent.McpProviders) != 1 || sent.McpProviders[0] != "github" {
|
|
t.Errorf("sent mcp mode/providers = %q / %v", sent.McpMode, sent.McpProviders)
|
|
}
|
|
if sent.McpProfile == nil {
|
|
t.Error("sent McpProfile = nil, want the backend-owned profile forwarded")
|
|
}
|
|
if !sent.RequiresProcessKill {
|
|
t.Error("sent RequiresProcessKill = false, want true")
|
|
}
|
|
if len(sent.StripEnv) != 1 || sent.StripEnv[0] != "ANTHROPIC_API_KEY" {
|
|
t.Errorf("sent StripEnv = %v", sent.StripEnv)
|
|
}
|
|
if sent.BaseBranches[""] != "origin/main" || sent.BaseBranches["web"] != "origin/develop" {
|
|
t.Errorf("sent BaseBranches = %v", sent.BaseBranches)
|
|
}
|
|
if _, ok := sent.RemoteContributions[""]; !ok {
|
|
t.Errorf("sent RemoteContributions = %v, want the root binding", sent.RemoteContributions)
|
|
}
|
|
if len(sent.WorkspaceSourceRoots) != 1 || sent.WorkspaceSourceRoots[0] != "/sources/a" {
|
|
t.Errorf("sent WorkspaceSourceRoots = %v", sent.WorkspaceSourceRoots)
|
|
}
|
|
|
|
if resp.ID != "inst-1" || resp.Port != 41234 {
|
|
t.Errorf("response = %+v, want inst-1 on port 41234", resp)
|
|
}
|
|
}
|
|
|
|
// TestCreateInstance_MarshalsMCPToolNamePresentationCapability covers the
|
|
// wire portion of AC-TASKS-MCP-TOOL-NAMES-001.5.
|
|
func TestCreateInstance_MarshalsMCPToolNamePresentationCapability(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusCreated, `{"id":"inst-1","port":41234}`))
|
|
req := &CreateInstanceRequest{
|
|
WorkspacePath: "/workspace/task-1",
|
|
NamespacesMCPToolsByServer: true,
|
|
}
|
|
|
|
if _, err := newTestControlClient(t, srv).CreateInstance(context.Background(), req); err != nil {
|
|
t.Fatalf("CreateInstance: %v", err)
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(got.Body, &body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if got, ok := body["namespaces_mcp_tools_by_server"].(bool); !ok || !got {
|
|
t.Fatalf("serialized capability = %#v, want true", body["namespaces_mcp_tools_by_server"])
|
|
}
|
|
}
|
|
|
|
// 200 is accepted alongside 201 — agentctl returns 200 when it reuses an
|
|
// existing instance for the same ID.
|
|
func TestCreateInstance_AcceptsBoth200And201(t *testing.T) {
|
|
for _, status := range []int{http.StatusOK, http.StatusCreated} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(status, `{"id":"i","port":1}`))
|
|
t.Cleanup(srv.Close)
|
|
|
|
resp, err := newTestControlClient(t, srv).CreateInstance(
|
|
context.Background(), &CreateInstanceRequest{WorkspacePath: "/w"})
|
|
if err != nil {
|
|
t.Fatalf("CreateInstance on %d: %v", status, err)
|
|
}
|
|
if resp.ID != "i" || resp.Port != 1 {
|
|
t.Errorf("response = %+v", resp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateInstance_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "structured error",
|
|
status: http.StatusConflict,
|
|
body: `{"error":"port range exhausted"}`,
|
|
wantErr: "failed to create instance: port range exhausted (status 409)",
|
|
},
|
|
{
|
|
name: "unparseable error body",
|
|
status: http.StatusBadGateway,
|
|
body: `<html>502</html>`,
|
|
wantErr: "failed to decode error response",
|
|
},
|
|
{
|
|
name: "malformed success body",
|
|
status: http.StatusCreated,
|
|
body: `{"id":`,
|
|
wantErr: "failed to decode response",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
resp, err := newTestControlClient(t, srv).CreateInstance(
|
|
context.Background(), &CreateInstanceRequest{WorkspacePath: "/w"})
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if resp != nil {
|
|
t.Errorf("response = %+v, want nil on failure", resp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteInstance_UsesDeleteVerbWithIDInPath(t *testing.T) {
|
|
for _, status := range []int{http.StatusOK, http.StatusNoContent} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(status, ``))
|
|
|
|
if err := newTestControlClient(t, srv).DeleteInstance(
|
|
context.Background(), "inst-9"); err != nil {
|
|
t.Fatalf("DeleteInstance: %v", err)
|
|
}
|
|
if got.Method != http.MethodDelete {
|
|
t.Errorf("method = %q, want DELETE", got.Method)
|
|
}
|
|
if got.Path != "/api/v1/instances/inst-9" {
|
|
t.Errorf("path = %q, want the ID as the last segment", got.Path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteInstance_LostResponseThenNotFoundIsSuccessfulRetry(t *testing.T) {
|
|
transport := &lostDeleteResponseTransport{}
|
|
client := NewControlClient("agentctl.test", 80, newTestLogger())
|
|
client.httpClient = &http.Client{Transport: transport}
|
|
|
|
if err := client.DeleteInstance(context.Background(), "inst-9"); err == nil {
|
|
t.Fatal("first DeleteInstance error = nil, want lost-response error")
|
|
}
|
|
if err := client.DeleteInstance(context.Background(), "inst-9"); err != nil {
|
|
t.Fatalf("DeleteInstance retry after completed delete: %v", err)
|
|
}
|
|
if transport.requests != 2 {
|
|
t.Fatalf("requests = %d, want 2", transport.requests)
|
|
}
|
|
}
|
|
|
|
type lostDeleteResponseTransport struct {
|
|
requests int
|
|
}
|
|
|
|
func (t *lostDeleteResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
t.requests++
|
|
if t.requests == 1 {
|
|
return nil, errors.New("delete response lost")
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Body: io.NopCloser(strings.NewReader(`{"error":"instance not found"}`)),
|
|
Header: make(http.Header),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
func TestDeleteInstance_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "structured error",
|
|
status: http.StatusConflict,
|
|
body: `{"error":"instance still stopping"}`,
|
|
wantErr: "failed to delete instance: instance still stopping (status 409)",
|
|
},
|
|
{
|
|
name: "unparseable error body",
|
|
status: http.StatusInternalServerError,
|
|
body: `boom`,
|
|
wantErr: "failed to decode error response",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
err := newTestControlClient(t, srv).DeleteInstance(context.Background(), "inst-9")
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetInstance_DecodesEveryField(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{
|
|
"id":"inst-3","port":40001,"status":"running",
|
|
"workspace_path":"/workspace/task-3","agent_command":"claude-code acp",
|
|
"env":{"FOO":"bar"},"created_at":"2026-08-10T12:34:56Z"
|
|
}`))
|
|
|
|
info, err := newTestControlClient(t, srv).GetInstance(context.Background(), "inst-3")
|
|
if err != nil {
|
|
t.Fatalf("GetInstance: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodGet || got.Path != "/api/v1/instances/inst-3" {
|
|
t.Errorf("request = %s %s, want GET /api/v1/instances/inst-3", got.Method, got.Path)
|
|
}
|
|
|
|
if info.ID != "inst-3" || info.Port != 40001 || info.Status != "running" {
|
|
t.Errorf("id/port/status = %q / %d / %q", info.ID, info.Port, info.Status)
|
|
}
|
|
if info.WorkspacePath != "/workspace/task-3" || info.AgentCommand != "claude-code acp" {
|
|
t.Errorf("workspace/command = %q / %q", info.WorkspacePath, info.AgentCommand)
|
|
}
|
|
if info.Env["FOO"] != "bar" {
|
|
t.Errorf("Env = %v, want FOO=bar", info.Env)
|
|
}
|
|
wantCreated := time.Date(2026, 8, 10, 12, 34, 56, 0, time.UTC)
|
|
if !info.CreatedAt.Equal(wantCreated) {
|
|
t.Errorf("CreatedAt = %v, want %v", info.CreatedAt, wantCreated)
|
|
}
|
|
}
|
|
|
|
func TestGetInstance_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"not found", http.StatusNotFound, `{}`, `instance "inst-3" not found`},
|
|
{"other status", http.StatusInternalServerError, `{}`, "failed to get instance: status 500"},
|
|
{"malformed body", http.StatusOK, `{"id":`, "failed to decode response"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
info, err := newTestControlClient(t, srv).GetInstance(context.Background(), "inst-3")
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if info != nil {
|
|
t.Errorf("info = %+v, want nil on failure", info)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The envelope-decode + request method/path contract this used to pin with a
|
|
// hand-written response fixture is now covered end to end (real handler, real
|
|
// client) by TestListInstancesReturnsEnvelopeWithSessionAndTaskID in
|
|
// internal/agentctl/server/api — a fixture here could assert an envelope
|
|
// shape the real handler had stopped producing without ever failing.
|
|
|
|
func TestListInstances_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"non-OK status", http.StatusBadGateway, `{}`, "failed to list instances: status 502"},
|
|
{"malformed body", http.StatusOK, `{"instances":`, "failed to decode response"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, tc.body))
|
|
t.Cleanup(srv.Close)
|
|
|
|
instances, err := newTestControlClient(t, srv).ListInstances(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if instances != nil {
|
|
t.Errorf("instances = %+v, want nil on failure", instances)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClaimOwnership_UnauthorizedReturnsSupersededSentinel pins
|
|
// AC-EXECUTORS-CONTROL-OWNERSHIP-002.3: a 401 response must be distinguished
|
|
// from any other failure via ErrOwnershipCredentialSuperseded, so a caller
|
|
// like OwnershipRenewer can tell "credential is no longer current" apart
|
|
// from a transient failure it should retry.
|
|
func TestClaimOwnership_UnauthorizedReturnsSupersededSentinel(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(http.StatusUnauthorized, `{"error":"invalid auth token"}`))
|
|
t.Cleanup(srv.Close)
|
|
|
|
err := newTestControlClient(t, srv).ClaimOwnership(context.Background())
|
|
if !errors.Is(err, ErrOwnershipCredentialSuperseded) {
|
|
t.Fatalf("error = %v, want errors.Is(err, ErrOwnershipCredentialSuperseded)", err)
|
|
}
|
|
}
|
|
|
|
func TestClaimOwnership_OtherFailureModesDoNotReturnSupersededSentinel(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
wantErr string
|
|
}{
|
|
{"internal server error", http.StatusInternalServerError, "failed to claim ownership: status 500"},
|
|
{"not found", http.StatusNotFound, "failed to claim ownership: status 404"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(tc.status, `{}`))
|
|
t.Cleanup(srv.Close)
|
|
|
|
err := newTestControlClient(t, srv).ClaimOwnership(context.Background())
|
|
if errors.Is(err, ErrOwnershipCredentialSuperseded) {
|
|
t.Fatalf("error = %v, want NOT errors.Is(err, ErrOwnershipCredentialSuperseded)", err)
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaimOwnership_SuccessReturnsNilError(t *testing.T) {
|
|
srv := httptest.NewServer(jsonResponder(http.StatusOK, `{}`))
|
|
t.Cleanup(srv.Close)
|
|
|
|
if err := newTestControlClient(t, srv).ClaimOwnership(context.Background()); err != nil {
|
|
t.Fatalf("ClaimOwnership() error = %v, want nil", err)
|
|
}
|
|
}
|