package docker import ( "context" "strings" "testing" "time" ) // argsString joins docker args for easy substring assertions. func argsString(a []string) string { return strings.Join(a, " ") } // hasFlagValue reports whether args contains flag immediately followed by value. func hasFlagValue(args []string, flag, value string) bool { for i := 0; i+1 < len(args); i++ { if args[i] == flag && args[i+1] == value { return true } } return false } func baseJob() Job { return Job{ Label: "unkin/teabot#issue-1", Image: "git.unkin.net/unkin/agent-dev:latest", ContainerHome: "/home/agent", Prompt: "do the thing", CloneURL: "https://git.unkin.net/unkin/teabot.git", GitHost: "git.unkin.net", GitName: "Impl Bot", GitEmail: "impl@unkin.net", GitUser: "implbot", Token: "secret-token", TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml", ClaudeConfigDir: "/home/ben/.claude", } } func TestBuildArgsCoreShape(t *testing.T) { r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"} args := r.buildArgs(baseJob(), "/tmp/job123") s := argsString(args) if args[0] != "run" { t.Errorf("first arg = %q, want run", args[0]) } if !hasFlagValue(args, "--entrypoint", "/bin/bash") { t.Error("missing --entrypoint /bin/bash") } if !strings.Contains(s, "--rm") { t.Error("missing --rm") } // Image and job script must be the trailing args. if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" { t.Errorf("trailing args = %v", args[len(args)-2:]) } } func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) { r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"} args := r.buildArgs(baseJob(), "/tmp/job123") s := argsString(args) // Prompt + job script mounted read-only with the SELinux relabel. if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") { t.Error("prompt mount missing or wrong flags") } if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") { t.Error("job.sh mount missing or wrong flags") } // Claude config copy mounted read-write with relabel. if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") { t.Error("claude mount missing or wrong flags") } // Tea config mounted read-only at the XDG path. if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") { t.Error("tea config mount missing or wrong flags") } } func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) { r := &DockerRunner{SELinuxLabel: "z"} args := r.buildArgs(baseJob(), "/tmp/j") checks := map[string]string{ "TEABOT_GIT_USER": "implbot", "TEABOT_TOKEN": "secret-token", "TEABOT_GIT_HOST": "git.unkin.net", "TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git", "TEABOT_HOME": "/home/agent", "XDG_CONFIG_HOME": "/home/agent/.config", } for k, v := range checks { if !hasFlagValue(args, "-e", k+"="+v) { t.Errorf("missing env -e %s=%s", k, v) } } } func TestBuildArgsAnthropicEnvOptIn(t *testing.T) { r := &DockerRunner{SELinuxLabel: "z"} // Without keys, no ANTHROPIC_* env should be injected. args := r.buildArgs(baseJob(), "/tmp/j") if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") { t.Error("ANTHROPIC_API_KEY injected when unset") } // With keys, both are injected. j := baseJob() j.AnthropicAPIKey = "sk-test" j.AnthropicBaseURL = "https://gw.example.com" args = r.buildArgs(j, "/tmp/j") if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") { t.Error("missing ANTHROPIC_API_KEY env") } if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") { t.Error("missing ANTHROPIC_BASE_URL env") } } func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) { r := &DockerRunner{SELinuxLabel: "z"} j := baseJob() j.ClaudeConfigDir = "" j.TeaConfigPath = "" s := argsString(r.buildArgs(j, "/tmp/j")) if strings.Contains(s, ".claude") { t.Error("claude mount present despite empty ClaudeConfigDir") } if strings.Contains(s, "tea/config.yml") { t.Error("tea mount present despite empty TeaConfigPath") } } func TestJobScriptIsBash(t *testing.T) { if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") { t.Error("job script missing bash shebang") } for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} { if !strings.Contains(jobScript, needed) { t.Errorf("job script missing %q", needed) } } } // fakeRunner demonstrates the Runner interface is satisfiable without docker. type fakeRunner struct{ jobs []Job } func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) { f.jobs = append(f.jobs, j) return Result{ExitCode: 0, Duration: time.Millisecond}, nil } func TestRunnerInterfaceSatisfiedByFake(t *testing.T) { var r Runner = &fakeRunner{} res, err := r.Run(context.Background(), baseJob()) if err != nil || res.ExitCode != 0 { t.Fatalf("fake runner: res=%+v err=%v", res, err) } }