package ci
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gopkg.in/yaml.v3"
)
// TWO LAUNCHES OF ONE WORKFLOW AT ONCE EACH START THE DEFINITION THEY
// REGISTERED. The engine starts a run by name, so a Register from one
// launch between another's Register and Start would run the wrong file
// under the right record.
func TestConcurrentLaunchesStartWhatTheyRegistered(t *testing.T) {
var mu sync.Mutex
current := map[string]string{} // name -> spec registered last
ran := map[string]string{} // run id -> spec current at its Start
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/ci/compile":
var body struct{ Name, Spec string }
json.NewDecoder(r.Body).Decode(&body)
time.Sleep(30 * time.Millisecond) // widen the window between register and start
mu.Lock()
current[body.Name] = body.Spec
mu.Unlock()
fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true}`, body.Name)
case r.URL.Path == "/runs" && r.Method == http.MethodPost:
var body struct{ Workflow string }
json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
n++
id := fmt.Sprintf("r%d", n)
ran[id] = current[body.Workflow]
mu.Unlock()
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"run_id":%q}`, id)
case r.URL.Path == "/runs":
fmt.Fprint(w, `[]`)
}
}))
defer srv.Close()
def := func(tag string) []byte {
return []byte("jobs:\n t:\n steps:\n - run: echo " + tag + "\n")
}
repo := &fakeRepo{files: []string{"ci/test.yml"}, blobs: map[string][]byte{"ci/test.yml": def("base")},
versioned: map[string]map[string][]byte{}}
for i := 0; i < 6; i++ {
repo.versioned[fmt.Sprintf("c%d", i)] = map[string][]byte{"ci/test.yml": def(fmt.Sprintf("v%d", i))}
}
st := &fakeStore{mark: map[string]string{}}
e := New(srv.URL)
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if err := RunAt(e, st, "alpha", repo, "u", fmt.Sprintf("c%d", i), ""); err != nil {
t.Error(err)
}
}(i)
}
wg.Wait()
st.mu.Lock()
defer st.mu.Unlock()
if len(st.runs) != 6 {
t.Fatalf("FAIL: %d runs recorded; want 6", len(st.runs))
}
for _, line := range st.runs {
id, checkin, _ := strings.Cut(line, " ")
checkin, _, _ = strings.Cut(checkin, " ")
want := "echo v" + strings.TrimPrefix(checkin, "c")
if !strings.Contains(ran[id], want) {
t.Errorf("FAIL: run %s of %s started %q; want its own definition (%s)", id, checkin, ran[id], want)
}
}
t.Log("ok: register and start of one workflow are one step for each launch")
}
// THE STARTER IS WHOEVER AUTHENTICATED TO CARRY THE ARTIFACT, NEVER A
// U CARD: a check-in's for a push, the tag's control artifact's for a
// tag. Nobody recorded is no starter.
func TestTheStarterIsTheAuthenticatedPusher(t *testing.T) {
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/ci/compile":
var body struct{ Name string }
json.NewDecoder(r.Body).Decode(&body)
fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true,"on":{"push":{},"tag":{}}}`, body.Name)
case r.URL.Path == "/runs" && r.Method == http.MethodPost:
n++
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"run_id":"r%d"}`, n)
case r.URL.Path == "/runs":
fmt.Fprint(w, `[]`)
}
}))
defer srv.Close()
e := New(srv.URL)
st := &fakeStore{mark: map[string]string{"alpha": "t0"}, pushedBy: map[string]string{"t1": "dev", "ctl": "dev"}}
repo := &fakeRepo{files: []string{"ci/w.yml"},
blobs: map[string][]byte{"ci/w.yml": []byte("on:\n push: {}\n tag: {}\njobs:\n t:\n steps:\n - run: x\n")},
news: []Checkin{{Hash: "t1", Branch: "trunk", User: "admin"}, {Hash: "t2", Branch: "trunk", User: "admin"}}}
if err := Trigger(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := st.starts["r1"].Starter; got != "dev" {
t.Errorf("FAIL: a push carried by dev with U admin started by %q; want dev", got)
}
if got := st.starts["r2"].Starter; got != "" {
t.Errorf("FAIL: a check-in nobody authenticated to carry started by %q; want nobody", got)
}
TriggerTags(e, st, "alpha", repo, "u") // first sight
repo.tags = []Tagged{{TagKey: TagKey{Src: "ctl", Target: "t1", Name: "version-1"}, At: Checkin{Hash: "t1", Branch: "trunk", User: "admin", Starter: "admin"}}}
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := st.starts["r3"]; got.Event != "tag" || got.Starter != "dev" {
t.Errorf("FAIL: a tag whose control artifact dev carried, signed U admin, recorded %+v; want started by dev", got)
}
t.Log("ok: the starter is the carrier the front authenticated, never the U card")
}
// PERMISSIONS IN A FLOW MAPPING, OR WITH A BLOCK SCALAR VALUE, ARE
// REFUSED WITH A POSITION, AND STRIPPING TAKES EVERY LINE A VALUE SPANS.
func TestPermissionsOddShapesAreRefusedAndStrippedWhole(t *testing.T) {
flow := []byte("jobs:\n t: {permissions: {uv: write}, steps: [{run: x}]}\n")
if _, err := permissionsOf(flow); err == nil || !strings.Contains(err.Error(), "line 2") {
t.Errorf("FAIL: permissions in a flow mapping gave %v; want a refusal with its line", err)
}
block := []byte("permissions: |\n uv: write\n contents: write\njobs:\n t:\n steps:\n - run: x\n")
if _, err := permissionsOf(block); err == nil || !strings.Contains(err.Error(), "line 1") {
t.Errorf("FAIL: a block scalar permissions gave %v; want a refusal", err)
}
out := withoutQuarryKeys(block)
if bytes.Count(out, []byte("\n")) != bytes.Count(block, []byte("\n")) || bytes.Contains(out, []byte("uv: write")) || bytes.Contains(out, []byte("contents: write")) {
t.Errorf("FAIL: the block scalar was not stripped whole:\n%s", out)
}
var doc map[string]any
if err := yaml.Unmarshal(out, &doc); err != nil || doc["jobs"] == nil {
t.Errorf("FAIL: the stripped spec %v:\n%s", err, out)
}
t.Log("ok: odd shapes refused with a position; a value is stripped to its last line")
}