You've already forked agentic-coding-workflow
357 lines
11 KiB
Go
357 lines
11 KiB
Go
package run
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
|
)
|
|
|
|
// ===== fakes =====
|
|
|
|
// fakeWsRepo 只实现 GetWorkspaceByID;其余方法经嵌入 interface 满足契约,被调用即 panic。
|
|
type fakeWsRepo struct {
|
|
workspace.Repository
|
|
ws *workspace.Workspace
|
|
err error
|
|
}
|
|
|
|
func (f *fakeWsRepo) GetWorkspaceByID(context.Context, uuid.UUID) (*workspace.Workspace, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.ws, nil
|
|
}
|
|
|
|
// fakeWtSvc 只实现 List。
|
|
type fakeWtSvc struct {
|
|
workspace.WorktreeService
|
|
wts []*workspace.Worktree
|
|
err error
|
|
}
|
|
|
|
func (f *fakeWtSvc) List(context.Context, workspace.Caller, uuid.UUID) ([]*workspace.Worktree, error) {
|
|
return f.wts, f.err
|
|
}
|
|
|
|
// fakePA 返回固定的 read/write 决策。
|
|
type fakePA struct {
|
|
canRead bool
|
|
canWrite bool
|
|
err error
|
|
}
|
|
|
|
func (f *fakePA) Resolve(context.Context, uuid.UUID, bool, string) (uuid.UUID, bool, bool, error) {
|
|
return uuid.Nil, f.canRead, f.canWrite, f.err
|
|
}
|
|
func (f *fakePA) ResolveByID(context.Context, uuid.UUID, bool, uuid.UUID) (bool, bool, error) {
|
|
return f.canRead, f.canWrite, f.err
|
|
}
|
|
|
|
func newExecSvc(t *testing.T, ws *workspace.Workspace, wts []*workspace.Worktree, pa workspace.ProjectAccess, cfg ExecConfig) *ExecService {
|
|
t.Helper()
|
|
return NewExecService(
|
|
&fakeWsRepo{ws: ws},
|
|
&fakeWtSvc{wts: wts},
|
|
pa,
|
|
nil, // audit recorder: nil → audit no-op
|
|
nil, // encryptor unused
|
|
cfg,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// hasCode 报告 err 是否携带给定 errs.Code。
|
|
func hasCode(err error, code errs.Code) bool {
|
|
ae, ok := errs.As(err)
|
|
return ok && ae.Code == code
|
|
}
|
|
|
|
func shellCmd(script string) (string, []string) {
|
|
if runtime.GOOS == "windows" {
|
|
return "cmd", []string{"/c", script}
|
|
}
|
|
return "sh", []string{"-c", script}
|
|
}
|
|
|
|
func baseCfg() ExecConfig {
|
|
return ExecConfig{
|
|
EnvWhitelist: []string{"PATH"},
|
|
DefaultTimeout: 10 * time.Second,
|
|
MaxTimeout: 30 * time.Second,
|
|
MaxOutputBytes: 1 << 20,
|
|
KillGrace: time.Second,
|
|
}
|
|
}
|
|
|
|
// ===== tests =====
|
|
|
|
func TestExecService_RunCommand_Success(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: dir}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
cmd, args := shellCmd("echo hi")
|
|
resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunCommand: %v", err)
|
|
}
|
|
if resp.ExitCode != 0 {
|
|
t.Fatalf("exit = %d", resp.ExitCode)
|
|
}
|
|
if !strings.Contains(resp.Stdout, "hi") {
|
|
t.Fatalf("stdout = %q", resp.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestExecService_AuthDenied(t *testing.T) {
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: false}, baseCfg())
|
|
|
|
cmd, args := shellCmd("echo hi")
|
|
_, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if !hasCode(err, errs.CodeForbidden) {
|
|
t.Fatalf("err = %v, want forbidden", err)
|
|
}
|
|
}
|
|
|
|
func TestExecService_CommandNotAllowed(t *testing.T) {
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
cfg := baseCfg()
|
|
cfg.AllowedCommands = []string{"go", "npm"}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, cfg)
|
|
|
|
cmd, args := shellCmd("echo hi")
|
|
_, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if !hasCode(err, errs.CodeRunExecCommandNotAllowed) {
|
|
t.Fatalf("err = %v, want run.exec_command_not_allowed", err)
|
|
}
|
|
}
|
|
|
|
func TestExecService_EmptyCommand(t *testing.T) {
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
_, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: " ",
|
|
})
|
|
if !hasCode(err, errs.CodeRunCommandInvalid) {
|
|
t.Fatalf("err = %v, want run.command_invalid", err)
|
|
}
|
|
}
|
|
|
|
// TestExecService_EnvWhitelist 验证子进程环境仅含白名单变量 + 请求覆盖,
|
|
// 绝不泄漏 APP_MASTER_KEY 等敏感宿主变量。
|
|
func TestExecService_EnvWhitelist(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("env dump via /bin/sh not portable on windows")
|
|
}
|
|
t.Setenv("APP_MASTER_KEY", "super-secret")
|
|
t.Setenv("PATH", os.Getenv("PATH"))
|
|
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: "env",
|
|
Env: map[string]string{"MY_OVERRIDE": "v1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunCommand: %v", err)
|
|
}
|
|
if strings.Contains(resp.Stdout, "super-secret") || strings.Contains(resp.Stdout, "APP_MASTER_KEY") {
|
|
t.Fatalf("env leaked master key:\n%s", resp.Stdout)
|
|
}
|
|
if !strings.Contains(resp.Stdout, "MY_OVERRIDE=v1") {
|
|
t.Fatalf("override missing from env:\n%s", resp.Stdout)
|
|
}
|
|
if !strings.Contains(resp.Stdout, "PATH=") {
|
|
t.Fatalf("whitelisted PATH missing from env:\n%s", resp.Stdout)
|
|
}
|
|
}
|
|
|
|
// TestExecService_CwdWorktree 验证 worktree_id 解析到 wt.Path。
|
|
func TestExecService_CwdWorktree(t *testing.T) {
|
|
mainDir := t.TempDir()
|
|
wtDir := t.TempDir()
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: mainDir}
|
|
wtID := uuid.New()
|
|
wts := []*workspace.Worktree{{ID: wtID, WorkspaceID: ws.ID, Path: wtDir}}
|
|
svc := newExecSvc(t, ws, wts, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
cmd, args := shellCmd(pwdScript())
|
|
resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
WorktreeID: wtID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunCommand: %v", err)
|
|
}
|
|
// 比较 base name 规避 /private 软链等差异。
|
|
if !strings.Contains(resp.Stdout, lastPathElem(wtDir)) {
|
|
t.Fatalf("cwd stdout %q does not contain worktree dir %q", resp.Stdout, wtDir)
|
|
}
|
|
}
|
|
|
|
// TestExecService_CwdMainPath 验证无 worktree_id 时使用 main_path。
|
|
func TestExecService_CwdMainPath(t *testing.T) {
|
|
mainDir := t.TempDir()
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: mainDir}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
cmd, args := shellCmd(pwdScript())
|
|
resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunCommand: %v", err)
|
|
}
|
|
if !strings.Contains(resp.Stdout, lastPathElem(mainDir)) {
|
|
t.Fatalf("cwd stdout %q does not contain main dir %q", resp.Stdout, mainDir)
|
|
}
|
|
}
|
|
|
|
func TestExecService_BadWorktree(t *testing.T) {
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
cmd, args := shellCmd("echo hi")
|
|
_, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
WorktreeID: uuid.New().String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
})
|
|
if !hasCode(err, errs.CodeNotFound) {
|
|
t.Fatalf("err = %v, want not_found", err)
|
|
}
|
|
}
|
|
|
|
// TestExecService_Timeout 验证超时返回 TimedOut。
|
|
func TestExecService_Timeout(t *testing.T) {
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()}
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg())
|
|
|
|
var cmd string
|
|
var args []string
|
|
if runtime.GOOS == "windows" {
|
|
cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"}
|
|
} else {
|
|
cmd, args = "sh", []string{"-c", "sleep 30"}
|
|
}
|
|
start := time.Now()
|
|
resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: cmd,
|
|
Args: args,
|
|
TimeoutSec: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunCommand: %v", err)
|
|
}
|
|
if !resp.TimedOut {
|
|
t.Fatal("expected TimedOut")
|
|
}
|
|
if time.Since(start) > 10*time.Second {
|
|
t.Fatalf("timeout took too long: %v", time.Since(start))
|
|
}
|
|
}
|
|
|
|
// TestExecService_RunTests 端到端跑 go test -json 解析。
|
|
func TestExecService_RunTests(t *testing.T) {
|
|
if _, err := os.Stat(os.Getenv("GOROOT")); os.Getenv("GOROOT") != "" && err != nil {
|
|
t.Skip("GOROOT not available")
|
|
}
|
|
dir := t.TempDir()
|
|
writeGoModule(t, dir)
|
|
ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: dir}
|
|
// 透传完整 PATH 让 go 可被定位。
|
|
cfg := baseCfg()
|
|
cfg.EnvWhitelist = []string{"PATH", "HOME", "GOPATH", "GOROOT", "GOCACHE", "SystemRoot", "TEMP", "TMP", "USERPROFILE", "LOCALAPPDATA"}
|
|
cfg.MaxTimeout = 120 * time.Second
|
|
cfg.DefaultTimeout = 120 * time.Second
|
|
svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, cfg)
|
|
|
|
resp, err := svc.RunTests(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecTestsReq{
|
|
WorkspaceID: ws.ID.String(),
|
|
Command: "go",
|
|
Args: []string{"test", "-json", "./..."},
|
|
Format: "gotest",
|
|
TimeoutSec: 100,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunTests: %v\nstderr:\n%s", err, resp.Stderr)
|
|
}
|
|
if resp.ParseError != "" {
|
|
t.Fatalf("parse error: %s\nstdout:\n%s", resp.ParseError, resp.Stdout)
|
|
}
|
|
if resp.Summary.Framework != "gotest" {
|
|
t.Fatalf("framework = %q", resp.Summary.Framework)
|
|
}
|
|
if resp.Summary.Passed < 1 || resp.Summary.Failed < 1 {
|
|
t.Fatalf("summary = %+v\nstdout:\n%s", resp.Summary, resp.Stdout)
|
|
}
|
|
}
|
|
|
|
// ===== helpers =====
|
|
|
|
func pwdScript() string {
|
|
if runtime.GOOS == "windows" {
|
|
return "cd"
|
|
}
|
|
return "pwd"
|
|
}
|
|
|
|
func lastPathElem(p string) string {
|
|
p = strings.TrimRight(p, "/\\")
|
|
if i := strings.LastIndexAny(p, "/\\"); i >= 0 {
|
|
return p[i+1:]
|
|
}
|
|
return p
|
|
}
|
|
|
|
func writeGoModule(t *testing.T, dir string) {
|
|
t.Helper()
|
|
mustWrite(t, dir+"/go.mod", "module selftest\n\ngo 1.21\n")
|
|
src := `package selftest
|
|
|
|
import "testing"
|
|
|
|
func TestPasses(t *testing.T) {}
|
|
|
|
func TestFails(t *testing.T) { t.Fatal("intentional failure") }
|
|
`
|
|
mustWrite(t, dir+"/selftest_test.go", src)
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|