package runner
import (
"context"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A TASK THAT CARRIES A WORKFLOW JOB RUNS THE JOB'S STEPS, NOT A MANIFEST.
//
// quarry RFC 0019: a job's `run:` steps travel in the step's metadata
// (JobKey), so a repository with YAML workflows needs no ci/runner.json.
// Steps run in order with the job's env under each step's, in the job's
// working directory unless a step names its own; the first failure ends
// the job; a job asking for secrets is refused until they can be
// delivered safely.
func TestATaskCarryingAJobRunsItsSteps(t *testing.T) {
job := func(j Job) map[string]string {
b, _ := json.Marshal(j)
return map[string]string{JobKey: string(b)}
}
with := func(id string, meta map[string]string) polled {
p := task(id, "c1")
p.Metadata = meta
return p
}
b := &fakeBridge{queue: map[string][]polled{"t-job": {
with("ok", job(Job{Env: map[string]string{"WHO": "job", "KEEP": "kept"}, Steps: []JobStep{
{Name: "first", Run: "echo first $WHO $KEEP"},
{Run: "echo second $WHO\npwd", Env: map[string]string{"WHO": "step"}, WorkingDirectory: "app"},
}})),
with("stops", job(Job{Steps: []JobStep{{Name: "breaks", Run: "false"}, {Name: "never", Run: "echo never ran"}}})),
with("secret", job(Job{Secrets: []string{"CLOUDFLARE_API_TOKEN"}, Steps: []JobStep{{Run: "echo deploying"}}})),
with("escape", job(Job{Steps: []JobStep{{Run: "true", WorkingDirectory: "../out"}}})),
with("stepsecret", job(Job{Steps: []JobStep{{Run: "echo deploying", Secrets: []string{"STEP_TOKEN"}}}})),
}}, resolved: map[string]map[string]any{}, logs: map[string]string{}}
srv := httptest.NewServer(b.handler())
defer srv.Close()
r, err := New(Config{
Bridge: srv.URL, Token: "WT", Tasks: []string{"t-job"}, MaxTasks: 1,
WorkDir: t.TempDir(), Checkout: appCheckout{}, Heartbeat: 50 * time.Millisecond, Logf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { r.Run(ctx); close(done) }()
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
b.mu.Lock()
n := len(b.resolved)
b.mu.Unlock()
if n == 5 {
break
}
}
cancel()
<-done
b.mu.Lock()
defer b.mu.Unlock()
if got := b.resolved["ok"]; got["action"] != "complete" {
t.Fatalf("FAIL: a good job resolved %v, log %q", got, b.logs["ok"])
}
log := b.logs["ok"]
if !strings.Contains(log, "first job kept") || !strings.Contains(log, "second step") || !strings.Contains(log, "/app\n") {
t.Errorf("FAIL: the job's log %q; want job env, step env over it, and the step's working directory", log)
}
if !strings.Contains(log, "$ first") || !strings.Contains(log, "$ echo second $WHO") {
t.Errorf("FAIL: the job's log %q; want each step labelled by its name or first line", log)
}
if got := b.resolved["stops"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "breaks") || strings.Contains(b.logs["stops"], "never ran") {
t.Errorf("FAIL: a failing step resolved %v with log %q; want the job to stop there, naming it", got, b.logs["stops"])
}
if got := b.resolved["secret"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "CLOUDFLARE_API_TOKEN") || strings.Contains(b.logs["secret"], "deploying") {
t.Errorf("FAIL: a job asking for a secret resolved %v with log %q; want it refused, unrun", got, b.logs["secret"])
}
if got := b.resolved["stepsecret"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "STEP_TOKEN") || strings.Contains(b.logs["stepsecret"], "deploying") {
t.Errorf("FAIL: a step asking for a secret resolved %v; want it refused, unrun", got)
}
if got := b.resolved["escape"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "leaves the tree") {
t.Errorf("FAIL: a step outside the tree resolved %v; want a refusal", got)
}
t.Log("ok: steps run in order with merged env and directory; failure stops; secrets and escapes refused")
}
// appCheckout writes a tree with an app/ directory and no manifest: a
// job needs none.
type appCheckout struct{}
func (appCheckout) Checkout(_ context.Context, dir, _ string) error {
return os.MkdirAll(filepath.Join(dir, "app"), 0o755)
}