mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
927 lines
28 KiB
Go
927 lines
28 KiB
Go
package agentcli
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type configPushCapture struct {
|
|
payload map[string]any
|
|
upload []byte
|
|
}
|
|
|
|
func newConfigPushServer(t *testing.T) (*httptest.Server, *configPushCapture) {
|
|
t.Helper()
|
|
capture := &configPushCapture{}
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/upload-request":
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/uploads/config-asset"})
|
|
case "/uploads/config-asset":
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
http.Error(w, "missing upload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
capture.upload, err = io.ReadAll(file)
|
|
if err != nil {
|
|
http.Error(w, "bad upload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": "tool-file-1"})
|
|
case "/agent-stub/config/push":
|
|
if err := json.NewDecoder(r.Body).Decode(&capture.payload); err != nil {
|
|
http.Error(w, "bad config", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"result": "success"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
return server, capture
|
|
}
|
|
|
|
func assertConfigPushItem(t *testing.T, payload map[string]any, key string, name string) {
|
|
t.Helper()
|
|
items, ok := payload[key].([]any)
|
|
if !ok || len(items) != 1 {
|
|
t.Fatalf("%s = %#v, want one item", key, payload[key])
|
|
}
|
|
item, ok := items[0].(map[string]any)
|
|
if !ok || item["name"] != name {
|
|
t.Fatalf("%s item = %#v", key, items[0])
|
|
}
|
|
fileRef, ok := item["file_ref"].(map[string]any)
|
|
if !ok || fileRef["kind"] != "tool_file" || fileRef["id"] != "tool-file-1" {
|
|
t.Fatalf("file_ref = %#v", item["file_ref"])
|
|
}
|
|
}
|
|
|
|
func TestConfigFilesPushUploadsFileAndPushesToolFileRef(t *testing.T) {
|
|
server, capture := newConfigPushServer(t)
|
|
defer server.Close()
|
|
|
|
filePath := filepath.Join(t.TempDir(), "guide.txt")
|
|
if err := os.WriteFile(filePath, []byte("guide"), 0o644); err != nil {
|
|
t.Fatalf("write config file: %v", err)
|
|
}
|
|
if err := RunConfigFilesPush(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
[]string{filePath},
|
|
); err != nil {
|
|
t.Fatalf("push config file: %v", err)
|
|
}
|
|
|
|
if string(capture.upload) != "guide" {
|
|
t.Fatalf("uploaded file = %q", capture.upload)
|
|
}
|
|
assertConfigPushItem(t, capture.payload, "files", "guide.txt")
|
|
}
|
|
|
|
func TestConfigSkillsPushUploadsArchiveAndPushesToolFileRef(t *testing.T) {
|
|
server, capture := newConfigPushServer(t)
|
|
defer server.Close()
|
|
|
|
skillDir := filepath.Join(t.TempDir(), "alpha")
|
|
if err := os.Mkdir(skillDir, 0o755); err != nil {
|
|
t.Fatalf("create skill directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Alpha\n"), 0o644); err != nil {
|
|
t.Fatalf("write SKILL.md: %v", err)
|
|
}
|
|
if err := RunConfigSkillsPush(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
[]string{skillDir},
|
|
); err != nil {
|
|
t.Fatalf("push config skill: %v", err)
|
|
}
|
|
|
|
archive, err := zip.NewReader(bytes.NewReader(capture.upload), int64(len(capture.upload)))
|
|
if err != nil {
|
|
t.Fatalf("open uploaded skill archive: %v", err)
|
|
}
|
|
foundSkillMD := false
|
|
for _, file := range archive.File {
|
|
if file.Name == "SKILL.md" {
|
|
foundSkillMD = true
|
|
break
|
|
}
|
|
}
|
|
if !foundSkillMD {
|
|
t.Fatalf("uploaded skill archive does not contain SKILL.md")
|
|
}
|
|
assertConfigPushItem(t, capture.payload, "skills", "alpha")
|
|
}
|
|
|
|
func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) {
|
|
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n", "reference.md": "guide"})
|
|
tests := []struct {
|
|
name string
|
|
kind string
|
|
assetName string
|
|
payload []byte
|
|
run func(*Environment, string) error
|
|
assertFiles func(*testing.T, string)
|
|
}{
|
|
{
|
|
name: "file",
|
|
kind: "file",
|
|
assetName: "guide.txt",
|
|
payload: []byte("guide"),
|
|
run: func(env *Environment, targetDir string) error {
|
|
return RunConfigFilesPull(env, []string{"guide.txt"}, targetDir, true)
|
|
},
|
|
assertFiles: func(t *testing.T, targetDir string) {
|
|
data, err := os.ReadFile(filepath.Join(targetDir, "guide.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read pulled config file: %v", err)
|
|
}
|
|
if string(data) != "guide" {
|
|
t.Fatalf("pulled config file = %q", data)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skill",
|
|
kind: "skill",
|
|
assetName: "alpha",
|
|
payload: skillArchive,
|
|
run: func(env *Environment, targetDir string) error {
|
|
return RunConfigSkillsPull(env, []string{"alpha"}, targetDir, true)
|
|
},
|
|
assertFiles: func(t *testing.T, targetDir string) {
|
|
data, err := os.ReadFile(filepath.Join(targetDir, "alpha", "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("read pulled skill: %v", err)
|
|
}
|
|
if string(data) != "# Alpha\n" {
|
|
t.Fatalf("pulled SKILL.md = %q", data)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var controlPayload map[string]any
|
|
dataPlaneCalls := 0
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/download-request":
|
|
if err := json.NewDecoder(r.Body).Decode(&controlPayload); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"filename": test.assetName,
|
|
"mime_type": "application/octet-stream",
|
|
"size": len(test.payload),
|
|
"download_url": server.URL + "/files/config-asset",
|
|
})
|
|
case "/files/config-asset":
|
|
dataPlaneCalls++
|
|
_, _ = w.Write(test.payload)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
targetDir := t.TempDir()
|
|
err := test.run(&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}, targetDir)
|
|
if err != nil {
|
|
t.Fatalf("pull config %s: %v", test.kind, err)
|
|
}
|
|
|
|
config, ok := controlPayload["config"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("control payload config = %#v", controlPayload["config"])
|
|
}
|
|
if config["kind"] != test.kind || config["name"] != test.assetName {
|
|
t.Fatalf("control payload config = %#v", config)
|
|
}
|
|
if controlPayload["for_frontend"] != false {
|
|
t.Fatalf("for_frontend = %#v", controlPayload["for_frontend"])
|
|
}
|
|
if dataPlaneCalls != 1 {
|
|
t.Fatalf("data-plane calls = %d, want 1", dataPlaneCalls)
|
|
}
|
|
test.assertFiles(t, targetDir)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigPullsDownloadConcurrentlyAndPreserveOutputOrder(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
kind string
|
|
names []string
|
|
payloads map[string][]byte
|
|
run func(*Environment, []string, string) error
|
|
assertOutput func(*testing.T, string, string)
|
|
}{
|
|
{
|
|
name: "files JSON output",
|
|
kind: "file",
|
|
names: []string{"first.txt", "second.txt"},
|
|
payloads: map[string][]byte{
|
|
"first.txt": []byte("first file"),
|
|
"second.txt": []byte("second file"),
|
|
},
|
|
run: func(env *Environment, names []string, targetDir string) error {
|
|
return RunConfigFilesPull(env, names, targetDir, true)
|
|
},
|
|
assertOutput: func(t *testing.T, targetDir string, output string) {
|
|
t.Helper()
|
|
var result struct {
|
|
Items []struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
|
t.Fatalf("parse JSON output %q: %v", output, err)
|
|
}
|
|
if len(result.Items) != 2 {
|
|
t.Fatalf("output items = %#v, want two items", result.Items)
|
|
}
|
|
for index, name := range []string{"first.txt", "second.txt"} {
|
|
if result.Items[index].Name != name {
|
|
t.Errorf("item %d name = %q, want %q", index, result.Items[index].Name, name)
|
|
}
|
|
wantPath := filepath.Join(targetDir, name)
|
|
if result.Items[index].Path != wantPath {
|
|
t.Errorf("item %d path = %q, want %q", index, result.Items[index].Path, wantPath)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skills text output",
|
|
kind: "skill",
|
|
names: []string{"alpha", "beta"},
|
|
payloads: map[string][]byte{
|
|
"alpha": zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"}),
|
|
"beta": zipFixture(t, map[string]string{"SKILL.md": "# Beta\n"}),
|
|
},
|
|
run: func(env *Environment, names []string, targetDir string) error {
|
|
return RunConfigSkillsPull(env, names, targetDir, false)
|
|
},
|
|
assertOutput: func(t *testing.T, targetDir string, output string) {
|
|
t.Helper()
|
|
want := filepath.Join(targetDir, "alpha") + "\n# Alpha\n\n" +
|
|
filepath.Join(targetDir, "beta") + "\n# Beta\n"
|
|
if output != want {
|
|
t.Errorf("text output = %q, want %q", output, want)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var server *httptest.Server
|
|
bothDownloadsStarted := make(chan struct{})
|
|
secondResponseWritten := make(chan struct{})
|
|
var startMu sync.Mutex
|
|
started := 0
|
|
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/download-request":
|
|
var request struct {
|
|
Config struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if request.Config.Kind != test.kind {
|
|
t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
|
|
}
|
|
if _, ok := test.payloads[request.Config.Name]; !ok {
|
|
http.Error(w, "unknown target", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"filename": request.Config.Name,
|
|
"size": len(test.payloads[request.Config.Name]),
|
|
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
|
|
})
|
|
case "/files/config-asset":
|
|
name := r.URL.Query().Get("name")
|
|
payload, ok := test.payloads[name]
|
|
if !ok {
|
|
http.Error(w, "unknown target", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
startMu.Lock()
|
|
started++
|
|
if started == len(test.names) {
|
|
close(bothDownloadsStarted)
|
|
}
|
|
startMu.Unlock()
|
|
|
|
select {
|
|
case <-bothDownloadsStarted:
|
|
case <-time.After(2 * time.Second):
|
|
http.Error(w, "downloads did not overlap", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
if name == test.names[0] {
|
|
select {
|
|
case <-secondResponseWritten:
|
|
case <-time.After(2 * time.Second):
|
|
http.Error(w, "second response did not finish first", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
}
|
|
|
|
_, _ = w.Write(payload)
|
|
if name == test.names[1] {
|
|
close(secondResponseWritten)
|
|
}
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
targetDir := t.TempDir()
|
|
output, err := captureConfigStdout(t, func() error {
|
|
return test.run(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
test.names,
|
|
targetDir,
|
|
)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pull config %s: %v", test.kind, err)
|
|
}
|
|
test.assertOutput(t, targetDir, output)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigFilesPullLimitsActiveRequestsAndRunsQueuedTarget(t *testing.T) {
|
|
names := []string{"one.txt", "two.txt", "three.txt", "four.txt", "five.txt"}
|
|
entered := make(chan string, len(names))
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
|
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/download-request":
|
|
var request struct {
|
|
Config struct {
|
|
Name string `json:"name"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
entered <- request.Config.Name
|
|
select {
|
|
case <-release:
|
|
case <-time.After(2 * time.Second):
|
|
http.Error(w, "request was not released", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"filename": request.Config.Name,
|
|
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
|
|
})
|
|
case "/files/config-asset":
|
|
_, _ = w.Write([]byte(r.URL.Query().Get("name")))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer func() {
|
|
unblock()
|
|
server.Close()
|
|
}()
|
|
|
|
done := make(chan error, 1)
|
|
targetDir := t.TempDir()
|
|
go func() {
|
|
done <- RunConfigFilesPull(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
names,
|
|
targetDir,
|
|
true,
|
|
)
|
|
}()
|
|
|
|
for range 4 {
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("four config pull requests did not enter concurrently")
|
|
}
|
|
}
|
|
select {
|
|
case <-entered:
|
|
t.Fatal("a fifth request entered above the four-request limit")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
unblock()
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("queued config pull request did not proceed after release")
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("pull config files: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("config files pull did not finish")
|
|
}
|
|
}
|
|
|
|
func TestConfigFilesPullReturnsAllFailuresInInputOrderAfterLaterFailureCompletesFirst(t *testing.T) {
|
|
names := []string{"earlier-failure.txt", "later-failure.txt"}
|
|
laterFailureEmitted := make(chan struct{})
|
|
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/download-request":
|
|
var request struct {
|
|
Config struct {
|
|
Name string `json:"name"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch request.Config.Name {
|
|
case names[0]:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"filename": request.Config.Name,
|
|
"download_url": server.URL + "/files/config-asset",
|
|
})
|
|
case names[1]:
|
|
http.Error(w, "later input failed first", http.StatusServiceUnavailable)
|
|
close(laterFailureEmitted)
|
|
return
|
|
default:
|
|
http.Error(w, "unknown target", http.StatusBadRequest)
|
|
}
|
|
case "/files/config-asset":
|
|
select {
|
|
case <-laterFailureEmitted:
|
|
case <-time.After(2 * time.Second):
|
|
http.Error(w, "later failure was not emitted", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
http.Error(w, "earlier input failed later", http.StatusBadGateway)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
done := make(chan error, 1)
|
|
targetDir := t.TempDir()
|
|
go func() {
|
|
done <- RunConfigFilesPull(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
names,
|
|
targetDir,
|
|
true,
|
|
)
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("error = nil, want both config pull failures")
|
|
}
|
|
errorText := err.Error()
|
|
earlierWrapper := `download config file "earlier-failure.txt"`
|
|
earlierMessage := "earlier input failed later"
|
|
laterWrapper := `request config file "later-failure.txt" download URL`
|
|
laterMessage := "later input failed first"
|
|
earlierWrapperIndex := strings.Index(errorText, earlierWrapper)
|
|
earlierMessageIndex := strings.Index(errorText, earlierMessage)
|
|
laterWrapperIndex := strings.Index(errorText, laterWrapper)
|
|
laterMessageIndex := strings.Index(errorText, laterMessage)
|
|
if earlierWrapperIndex == -1 || earlierMessageIndex == -1 || laterWrapperIndex == -1 || laterMessageIndex == -1 {
|
|
t.Fatalf(
|
|
"error = %q, want both wrapped failures with messages %q and %q",
|
|
errorText,
|
|
earlierMessage,
|
|
laterMessage,
|
|
)
|
|
}
|
|
if earlierWrapperIndex >= earlierMessageIndex || earlierMessageIndex >= laterWrapperIndex || laterWrapperIndex >= laterMessageIndex {
|
|
t.Fatalf("error = %q, want failures in input order", errorText)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("config files pull did not finish")
|
|
}
|
|
}
|
|
|
|
func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
|
|
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"})
|
|
tests := []struct {
|
|
name string
|
|
kind string
|
|
names []string
|
|
payload []byte
|
|
failureStage string
|
|
wantStage string
|
|
}{
|
|
{
|
|
name: "file control-plane URL acquisition",
|
|
kind: "file",
|
|
names: []string{"first.txt", "second.txt"},
|
|
payload: []byte("first file"),
|
|
failureStage: "control",
|
|
wantStage: `request config file "second.txt" download URL`,
|
|
},
|
|
{
|
|
name: "file signed-URL download",
|
|
kind: "file",
|
|
names: []string{"first.txt", "second.txt"},
|
|
payload: []byte("first file"),
|
|
failureStage: "data",
|
|
wantStage: `download config file "second.txt"`,
|
|
},
|
|
{
|
|
name: "skill control-plane URL acquisition",
|
|
kind: "skill",
|
|
names: []string{"alpha", "beta"},
|
|
payload: skillArchive,
|
|
failureStage: "control",
|
|
wantStage: `request config skill "beta" download URL`,
|
|
},
|
|
{
|
|
name: "skill signed-URL download",
|
|
kind: "skill",
|
|
names: []string{"alpha", "beta"},
|
|
payload: skillArchive,
|
|
failureStage: "data",
|
|
wantStage: `download config skill "beta"`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var controlCalls atomic.Int32
|
|
var dataPlaneCalls atomic.Int32
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/download-request":
|
|
controlCalls.Add(1)
|
|
var request struct {
|
|
Config struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode download request: %v", err)
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if request.Config.Kind != test.kind {
|
|
t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
|
|
}
|
|
if test.failureStage == "control" && request.Config.Name == test.names[1] {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"filename": request.Config.Name,
|
|
"size": len(test.payload),
|
|
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
|
|
})
|
|
case "/files/config-asset":
|
|
dataPlaneCalls.Add(1)
|
|
if test.failureStage == "data" && r.URL.Query().Get("name") == test.names[1] {
|
|
http.Error(w, "data plane unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
_, _ = w.Write(test.payload)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
targetDir := t.TempDir()
|
|
env := &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}
|
|
var err error
|
|
if test.kind == "file" {
|
|
err = RunConfigFilesPull(env, test.names, targetDir, true)
|
|
} else {
|
|
err = RunConfigSkillsPull(env, test.names, targetDir, true)
|
|
}
|
|
if err == nil {
|
|
t.Fatal("config pull succeeded, want second-item failure")
|
|
}
|
|
if !strings.Contains(err.Error(), test.wantStage) {
|
|
t.Errorf("error = %q, want stage %q", err, test.wantStage)
|
|
}
|
|
if controlCalls.Load() != 2 {
|
|
t.Errorf("control-plane calls = %d, want 2", controlCalls.Load())
|
|
}
|
|
wantDataPlaneCalls := 2
|
|
if test.failureStage == "control" {
|
|
wantDataPlaneCalls = 1
|
|
for _, want := range []string{
|
|
"expired after 5 minutes",
|
|
"will not refresh automatically",
|
|
"start a new shell tool call",
|
|
"retry the command",
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error = %q, want expiration guidance %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
if int(dataPlaneCalls.Load()) != wantDataPlaneCalls {
|
|
t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls.Load(), wantDataPlaneCalls)
|
|
}
|
|
|
|
if test.kind == "file" {
|
|
data, readErr := os.ReadFile(filepath.Join(targetDir, test.names[0]))
|
|
if readErr != nil || !bytes.Equal(data, test.payload) {
|
|
t.Errorf("first file was not completed: data=%q err=%v", data, readErr)
|
|
}
|
|
} else {
|
|
data, readErr := os.ReadFile(filepath.Join(targetDir, test.names[0], "SKILL.md"))
|
|
if readErr != nil || string(data) != "# Alpha\n" {
|
|
t.Errorf("first skill was not completed: data=%q err=%v", data, readErr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigPushMultiItemUploadFailuresIdentifyItemAndStage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
failureStage string
|
|
makeSources func(*testing.T) []string
|
|
run func(*Environment, []string) error
|
|
wantItem string
|
|
}{
|
|
{
|
|
name: "file control-plane URL acquisition",
|
|
failureStage: "request upload URL",
|
|
makeSources: makeConfigFileSources,
|
|
run: RunConfigFilesPush,
|
|
wantItem: `config file "second.txt"`,
|
|
},
|
|
{
|
|
name: "file signed URL data transfer",
|
|
failureStage: "upload data",
|
|
makeSources: makeConfigFileSources,
|
|
run: RunConfigFilesPush,
|
|
wantItem: `config file "second.txt"`,
|
|
},
|
|
{
|
|
name: "skill control-plane URL acquisition",
|
|
failureStage: "request upload URL",
|
|
makeSources: makeConfigSkillSources,
|
|
run: RunConfigSkillsPush,
|
|
wantItem: `config skill "beta"`,
|
|
},
|
|
{
|
|
name: "skill signed URL data transfer",
|
|
failureStage: "upload data",
|
|
makeSources: makeConfigSkillSources,
|
|
run: RunConfigSkillsPush,
|
|
wantItem: `config skill "beta"`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
uploadRequestCalls := 0
|
|
dataPlaneCalls := 0
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/upload-request":
|
|
uploadRequestCalls++
|
|
if test.failureStage == "request upload URL" && uploadRequestCalls == 2 {
|
|
http.Error(w, "control plane unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/upload"})
|
|
case "/upload":
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
dataPlaneCalls++
|
|
if test.failureStage == "upload data" && dataPlaneCalls == 2 {
|
|
http.Error(w, "data plane unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"id":"tool-file-1"}`))
|
|
case "/agent-stub/config/push":
|
|
t.Error("final config push must not run after an item upload failure")
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
err := test.run(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
test.makeSources(t),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("config push succeeded, want second-item upload failure")
|
|
}
|
|
if !strings.Contains(err.Error(), test.wantItem) {
|
|
t.Errorf("error = %q, want current item %q", err, test.wantItem)
|
|
}
|
|
if !strings.Contains(err.Error(), test.failureStage) {
|
|
t.Errorf("error = %q, want stage %q", err, test.failureStage)
|
|
}
|
|
if uploadRequestCalls != 2 {
|
|
t.Errorf("upload request calls = %d, want 2", uploadRequestCalls)
|
|
}
|
|
wantDataPlaneCalls := 2
|
|
if test.failureStage == "request upload URL" {
|
|
wantDataPlaneCalls = 1
|
|
}
|
|
if dataPlaneCalls != wantDataPlaneCalls {
|
|
t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls, wantDataPlaneCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigPushFinalFailureIdentifiesOperationAndExplainsExpiry(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
makeSource func(*testing.T) string
|
|
run func(*Environment, string) error
|
|
want string
|
|
}{
|
|
{
|
|
name: "file",
|
|
makeSource: func(t *testing.T) string {
|
|
path := filepath.Join(t.TempDir(), "guide.txt")
|
|
if err := os.WriteFile(path, []byte("guide"), 0o600); err != nil {
|
|
t.Fatalf("write config file: %v", err)
|
|
}
|
|
return path
|
|
},
|
|
run: func(env *Environment, path string) error { return RunConfigFilesPush(env, []string{path}) },
|
|
want: "push config files",
|
|
},
|
|
{
|
|
name: "skill",
|
|
makeSource: func(t *testing.T) string {
|
|
dir := filepath.Join(t.TempDir(), "alpha")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("create config skill: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Alpha\n"), 0o600); err != nil {
|
|
t.Fatalf("write config skill: %v", err)
|
|
}
|
|
return dir
|
|
},
|
|
run: func(env *Environment, path string) error { return RunConfigSkillsPush(env, []string{path}) },
|
|
want: "push config skills",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/agent-stub/files/upload-request":
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/upload"})
|
|
case "/upload":
|
|
_, _ = w.Write([]byte(`{"id":"tool-file-1"}`))
|
|
case "/agent-stub/config/push":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
err := test.run(
|
|
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
|
|
test.makeSource(t),
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %v, want operation %q", err, test.want)
|
|
}
|
|
if !strings.Contains(err.Error(), "start a new shell tool call") {
|
|
t.Fatalf("error = %v, want expiry recovery guidance", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeConfigFileSources(t *testing.T) []string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
paths := []string{filepath.Join(dir, "first.txt"), filepath.Join(dir, "second.txt")}
|
|
for _, path := range paths {
|
|
if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o600); err != nil {
|
|
t.Fatalf("write config file: %v", err)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func makeConfigSkillSources(t *testing.T) []string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
paths := []string{filepath.Join(dir, "alpha"), filepath.Join(dir, "beta")}
|
|
for _, path := range paths {
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("create config skill: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("# "+filepath.Base(path)+"\n"), 0o600); err != nil {
|
|
t.Fatalf("write config skill: %v", err)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func zipFixture(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
var buffer bytes.Buffer
|
|
archive := zip.NewWriter(&buffer)
|
|
for name, content := range files {
|
|
writer, err := archive.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create zip member: %v", err)
|
|
}
|
|
if _, err := writer.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write zip member: %v", err)
|
|
}
|
|
}
|
|
if err := archive.Close(); err != nil {
|
|
t.Fatalf("close zip fixture: %v", err)
|
|
}
|
|
return buffer.Bytes()
|
|
}
|
|
|
|
func captureConfigStdout(t *testing.T, run func() error) (string, error) {
|
|
t.Helper()
|
|
outputFile, err := os.CreateTemp(t.TempDir(), "config-stdout-*")
|
|
if err != nil {
|
|
t.Fatalf("create stdout capture: %v", err)
|
|
}
|
|
originalStdout := os.Stdout
|
|
var runErr error
|
|
func() {
|
|
defer func() { os.Stdout = originalStdout }()
|
|
os.Stdout = outputFile
|
|
runErr = run()
|
|
}()
|
|
if err := outputFile.Close(); err != nil {
|
|
t.Fatalf("close stdout capture: %v", err)
|
|
}
|
|
output, err := os.ReadFile(outputFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("read stdout capture: %v", err)
|
|
}
|
|
return string(output), runErr
|
|
}
|