gitmote / internal / ci / dispatcher_test.go · history · branches
19476 bytes · raw
package ci
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
)
// newFixture wires a local meta, an in-memory store, and a materializer — the
// real collaborators the dispatcher reads workflow config through.
func newFixture(t *testing.T) (*meta.Metadata, store.Store, *repo.Materializer) {
t.Helper()
md, err := meta.Open(context.Background(), meta.Config{
LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
})
if err != nil {
t.Fatalf("meta.Open: %v", err)
}
t.Cleanup(func() { _ = md.Close() })
s := store.NewMem()
mz := repo.New(md, s, t.TempDir())
return md, s, mz
}
// gitFixture runs a git command hermetically, failing the test on nonzero exit.
func gitFixture(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
// seedRepo builds a real repo with the given files, loads its objects into the
// store the materializer reads, records the repo + branch ref in meta, and
// returns the repo row and the head SHA. An empty files map makes a repo with a
// single placeholder file (so the commit is non-empty) and no workflows.
func seedRepo(t *testing.T, md *meta.Metadata, s store.Store, name string, files map[string]string) (*meta.Repo, string) {
t.Helper()
ctx := context.Background()
src := t.TempDir()
gitFixture(t, src, "init", "-b", "main", ".")
if len(files) == 0 {
files = map[string]string{"README.md": "# repo\n"}
}
for rel, content := range files {
full := filepath.Join(src, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
gitFixture(t, src, "add", "-A")
gitFixture(t, src, "commit", "-m", "seed")
head := gitFixture(t, src, "rev-parse", "HEAD")
objRoot := filepath.Join(src, ".git", "objects")
err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(objRoot, p)
if err != nil {
return err
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
return s.Put(ctx, name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
})
if err != nil {
t.Fatalf("seed objects: %v", err)
}
r, err := md.CreateRepo(ctx, name, "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
if err := md.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head); err != nil {
t.Fatalf("CASRef: %v", err)
}
return r, head
}
func branchEvent(r *meta.Repo, sha string) Event {
return Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: meta.ZeroSHA, NewSHA: sha}
}
// stubTrigger records the env of each trigger and can be made to fail.
type stubTrigger struct {
calls []map[string]string
err error
}
func (s *stubTrigger) Trigger(_ context.Context, env map[string]string) error {
s.calls = append(s.calls, env)
return s.err
}
func newDispatcher(md *meta.Metadata, mz *repo.Materializer, tr Trigger) *Dispatcher {
return NewDispatcher(Config{
Runs: md,
Materializer: mz,
Trigger: tr,
BaseURL: "https://gitmote.test",
WorkerSecret: "worker-secret",
})
}
func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
})
tr := &stubTrigger{}
newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
if runs[0].Status != meta.RunQueued || runs[0].SHA != head {
t.Errorf("run = %+v, want queued at %s", runs[0], head)
}
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 1 || jobs[0].Name != "ci.yml" || jobs[0].Status != meta.RunQueued {
t.Errorf("jobs = %+v, want one queued job named ci.yml", jobs)
}
// The job was triggered exactly once, with the runner env for that run/job.
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
}
env := tr.calls[0]
if env["GITMOTE_CI_RUN_ID"] != strconv.FormatInt(runs[0].ID, 10) ||
env["GITMOTE_CI_JOB_ID"] != strconv.FormatInt(jobs[0].ID, 10) {
t.Errorf("env run/job ids = %q/%q, want %d/%d",
env["GITMOTE_CI_RUN_ID"], env["GITMOTE_CI_JOB_ID"], runs[0].ID, jobs[0].ID)
}
if env["GITMOTE_REPO"] != "app" || env["GITMOTE_SHA"] != head ||
env["GITMOTE_REF"] != "refs/heads/main" {
t.Errorf("env checkout coords = %+v, want repo/sha/ref set", env)
}
if env["GITMOTE_URL"] != "https://gitmote.test" || env["WORKER_SECRET"] != "worker-secret" {
t.Errorf("env url/secret = %q/%q, want configured values", env["GITMOTE_URL"], env["WORKER_SECRET"])
}
}
func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
".gitmote/workflows/deploy.yaml": "name: Deploy\non: push\n",
})
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 2 {
t.Fatalf("jobs = %d, want 2", len(jobs))
}
names := map[string]bool{jobs[0].Name: true, jobs[1].Name: true}
if !names["ci.yml"] || !names["deploy.yaml"] {
t.Errorf("job names = %v, want ci.yml + deploy.yaml", names)
}
}
// TestDispatchBranchFilterSkipsNonMatchingPush: a workflow gated on a branch
// filter (gitmote's own deploy.yml, `on.push.branches: [self-deploy]`) creates no
// run on a push to any other branch — like GitHub, a run appears only when the
// push actually triggers something.
func TestDispatchBranchFilterSkipsNonMatchingPush(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/deploy.yml": "name: Deploy\non:\n push:\n branches: [self-deploy]\n",
})
d := newDispatcher(md, mz, &stubTrigger{})
// A push to main matches nothing → no run at all.
d.Dispatch(ctx, Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: meta.ZeroSHA, NewSHA: head})
if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
t.Fatalf("main push runs = %d, want 0 (branch filter excludes main)", len(runs))
}
// A push to self-deploy matches → one run with the deploy job.
d.Dispatch(ctx, Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/self-deploy", OldSHA: meta.ZeroSHA, NewSHA: head})
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("self-deploy push runs = %d, want 1", len(runs))
}
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 1 || jobs[0].Name != "deploy.yml" {
t.Errorf("jobs = %+v, want one deploy.yml job", jobs)
}
}
func TestDispatchNoWorkflowsNoRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", nil) // no .gitmote/workflows
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
t.Errorf("runs = %d, want 0 (no workflows)", len(runs))
}
}
// TestDispatchGitHubWorkflowsIgnored: a repo whose workflows live in
// .github/workflows (a GitHub-mirrored repo, or gitmote's own repo) triggers no
// gitmote run — gitmote reads .gitmote/workflows, so the directory declares the
// forge and there is no double-run.
func TestDispatchGitHubWorkflowsIgnored(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
})
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
t.Errorf("runs = %d, want 0 (.github/workflows is GitHub's, not gitmote's)", len(runs))
}
}
func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/good.yml": "name: Good\non: push\n",
".gitmote/workflows/bad.yml": "name: Bad\n : : not: valid: yaml\n\t- broken\n",
})
// Must not panic; the push (simulated by the event) is unaffected.
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
if runs[0].Status != meta.RunFailed {
t.Errorf("run status = %q, want failed", runs[0].Status)
}
// The parsable file still got a job row, so the failure is visible in the UI.
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 1 || jobs[0].Name != "good.yml" {
t.Errorf("jobs = %+v, want just the parsable good.yml", jobs)
}
}
func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
d := newDispatcher(md, mz, &stubTrigger{})
cases := []Event{
{RepoID: r.ID, RepoName: r.Name, Ref: "refs/tags/v1", OldSHA: meta.ZeroSHA, NewSHA: head},
{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: head, NewSHA: meta.ZeroSHA},
{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: head, NewSHA: ""},
{RepoID: r.ID, RepoName: r.Name, Ref: "refs/notes/commits", OldSHA: meta.ZeroSHA, NewSHA: head},
}
for _, ev := range cases {
d.Dispatch(ctx, ev)
}
if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
t.Errorf("runs = %d, want 0 (tags/deletes/non-branch create none)", len(runs))
}
}
func TestDispatchAllTriggersFailMarksRunError(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{err: errors.New("scaleway down")}
newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 || runs[0].Status != meta.RunError {
t.Fatalf("run = %+v, want one run in error", runs)
}
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 1 || jobs[0].Status != meta.RunError {
t.Errorf("jobs = %+v, want the job marked error", jobs)
}
}
func TestDispatchOneTriggerFailsOthersProceed(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/a.yml": "name: A\non: push\n",
".gitmote/workflows/b.yml": "name: B\non: push\n",
})
// Fail the first trigger, succeed the rest — one failure must not abort others.
tr := &failNthTrigger{failOn: 1}
newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
// Not every job failed, so the run is not marked error; it stays queued.
if runs[0].Status != meta.RunQueued {
t.Errorf("run status = %q, want queued (not all jobs failed)", runs[0].Status)
}
jobs, _ := md.ListJobs(ctx, runs[0].ID)
if len(jobs) != 2 {
t.Fatalf("jobs = %d, want 2 (both created despite one trigger failure)", len(jobs))
}
var errored, queued int
for _, j := range jobs {
switch j.Status {
case meta.RunError:
errored++
case meta.RunQueued:
queued++
}
}
if errored != 1 || queued != 1 {
t.Errorf("job statuses = %+v, want one error + one queued", jobs)
}
}
// stubMinter records the MintScoped call and returns a fixed token.
type stubMinter struct {
token string
err error
userID int64
repoScope *int64
readOnly bool
expiresAt time.Time
}
func (m *stubMinter) MintScoped(_ context.Context, userID int64, _ string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error) {
m.userID = userID
m.repoScope = repoScope
m.readOnly = readOnly
m.expiresAt = expiresAt
return m.token, m.err
}
func TestDispatchMintsScopedCloneToken(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{}
mint := &stubMinter{token: "gmt_scoped.tok"}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr, Minter: mint,
BaseURL: "https://gitmote.test", WorkerSecret: "worker-secret",
}).Dispatch(ctx, Event{
RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main",
OldSHA: meta.ZeroSHA, NewSHA: head, PusherID: 42,
})
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
}
if got := tr.calls[0]["GITMOTE_CI_CLONE_TOKEN"]; got != "gmt_scoped.tok" {
t.Errorf("GITMOTE_CI_CLONE_TOKEN = %q, want the minted token", got)
}
// Minted under the pusher, read-only, scoped to just this repo, and expiring.
if mint.userID != 42 {
t.Errorf("mint userID = %d, want the pusher (42)", mint.userID)
}
if mint.repoScope == nil || *mint.repoScope != r.ID {
t.Errorf("mint repoScope = %v, want &%d", mint.repoScope, r.ID)
}
if !mint.readOnly {
t.Error("mint readOnly = false, want true — the clone token must be read-only")
}
if mint.expiresAt.IsZero() {
t.Error("mint expiresAt is zero, want a bounded TTL")
}
}
func TestDispatchMintFailureLeavesRunUsable(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// A mint failure is non-fatal: the run and job still record and trigger, with
// an empty clone token (the runner's clone fails visibly, not the push).
tr := &stubTrigger{}
mint := &stubMinter{err: errors.New("mint down")}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr, Minter: mint,
BaseURL: "https://gitmote.test", WorkerSecret: "worker-secret",
}).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1 despite mint failure", len(runs))
}
if len(tr.calls) != 1 || tr.calls[0]["GITMOTE_CI_CLONE_TOKEN"] != "" {
t.Errorf("want one trigger with an empty clone token, got %+v", tr.calls)
}
}
// stubSecrets returns a fixed secret map (or an error) for any repo.
type stubSecrets struct {
env map[string]string
err error
}
func (s stubSecrets) Secrets(context.Context, int64) (map[string]string, error) {
return s.env, s.err
}
func TestDispatchInjectsSecrets(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr,
Secrets: stubSecrets{env: map[string]string{"API_TOKEN": "s3cr3t"}},
BaseURL: "https://gitmote.test",
WorkerSecret: "worker-secret",
}).Dispatch(ctx, branchEvent(r, head))
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
}
env := tr.calls[0]
// Secrets are namespaced so the engine can tell them from coordinates.
if env["GITMOTE_CI_SECRET_API_TOKEN"] != "s3cr3t" {
t.Errorf("GITMOTE_CI_SECRET_API_TOKEN = %q, want the injected value", env["GITMOTE_CI_SECRET_API_TOKEN"])
}
// The namespacing keeps a secret from ever shadowing the runner's coordinates.
if env["GITMOTE_URL"] != "https://gitmote.test" || env["GITMOTE_REPO"] != "app" {
t.Errorf("coordinates altered by secret injection: %+v", env)
}
}
func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// A secrets error is non-fatal: the job still triggers, just without secrets.
tr := &stubTrigger{}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr,
Secrets: stubSecrets{err: errors.New("keyring down")},
BaseURL: "https://gitmote.test",
WorkerSecret: "worker-secret",
}).Dispatch(ctx, branchEvent(r, head))
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1 (secrets failure must not abort dispatch)", len(tr.calls))
}
if tr.calls[0]["GITMOTE_REPO"] != "app" {
t.Errorf("job env missing fixed coords: %+v", tr.calls[0])
}
}
func TestDispatchInjectsEnvSecrets(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// No DB Secrets service configured at all — env secrets alone must inject,
// since they need no master key (the fully-automated .env path).
tr := &stubTrigger{}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr,
EnvSecrets: LoadEnvSecrets([]string{"GITMOTE_REPO_SECRET_APP__DEPLOY_KEY=k3y"}),
BaseURL: "https://gitmote.test",
WorkerSecret: "worker-secret",
}).Dispatch(ctx, branchEvent(r, head))
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
}
if got := tr.calls[0]["GITMOTE_CI_SECRET_DEPLOY_KEY"]; got != "k3y" {
t.Errorf("GITMOTE_CI_SECRET_DEPLOY_KEY = %q, want the env-seeded value", got)
}
}
func TestDispatchEnvSecretsOverrideDBSecrets(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// On a name collision the env value wins; a DB-only secret still comes through.
tr := &stubTrigger{}
NewDispatcher(Config{
Runs: md, Materializer: mz, Trigger: tr,
Secrets: stubSecrets{env: map[string]string{"TOKEN": "from-db", "DB_ONLY": "keep"}},
EnvSecrets: LoadEnvSecrets([]string{"GITMOTE_REPO_SECRET_APP__TOKEN=from-env"}),
BaseURL: "https://gitmote.test",
WorkerSecret: "worker-secret",
}).Dispatch(ctx, branchEvent(r, head))
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
}
env := tr.calls[0]
if env["GITMOTE_CI_SECRET_TOKEN"] != "from-env" {
t.Errorf("collision: TOKEN = %q, want env to win (from-env)", env["GITMOTE_CI_SECRET_TOKEN"])
}
if env["GITMOTE_CI_SECRET_DB_ONLY"] != "keep" {
t.Errorf("DB_ONLY = %q, want the DB secret preserved", env["GITMOTE_CI_SECRET_DB_ONLY"])
}
}
// failNthTrigger fails the Nth (1-based) trigger call and succeeds the others.
type failNthTrigger struct {
failOn int
n int
}
func (f *failNthTrigger) Trigger(_ context.Context, _ map[string]string) error {
f.n++
if f.n == f.failOn {
return errors.New("trigger failed")
}
return nil
}