Files
k3nny b21ef5c0bb
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s
feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
- Add --changes PATH and --changes-from REF flags to glint check and glint graph
  for rules:changes: evaluation. --changes marks files explicitly; --changes-from
  runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
  EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
  removed provably dead code; added testability seams (exit, userHomeDirFn,
  execCommandOutput variables) to cover previously unreachable paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:47:32 +02:00

342 lines
12 KiB
Go

package fetcher
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// roundTripFunc allows constructing a custom http.RoundTripper from a function.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// errReader is an io.Reader that always returns an error.
type errReader struct{}
func (e errReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
// replaceTransport temporarily replaces http.DefaultTransport and restores it.
func replaceTransport(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = rt
t.Cleanup(func() { http.DefaultTransport = orig })
}
// ── firstNonEmpty ─────────────────────────────────────────────────────────────
func TestFirstNonEmpty(t *testing.T) {
if firstNonEmpty("", "", "c") != "c" { t.Error("should return first non-empty") }
if firstNonEmpty("a", "b") != "a" { t.Error("should return first") }
if firstNonEmpty("", "") != "" { t.Error("all empty → empty") }
}
// ── AutoConfig ────────────────────────────────────────────────────────────────
func TestAutoConfig(t *testing.T) {
// Clear relevant env vars
for _, k := range []string{"CI_SERVER_URL", "GITLAB_URL", "GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
os.Unsetenv(k)
}
cfg := AutoConfig()
if cfg.BaseURL != "https://gitlab.com" {
t.Errorf("default BaseURL: %q", cfg.BaseURL)
}
if cfg.Token != "" {
t.Error("expected no token")
}
}
func TestAutoConfig_EnvVars(t *testing.T) {
os.Setenv("CI_SERVER_URL", "https://my.gitlab.example.com/")
os.Setenv("GITLAB_TOKEN", "glpat-test")
defer func() {
os.Unsetenv("CI_SERVER_URL")
os.Unsetenv("GITLAB_TOKEN")
}()
cfg := AutoConfig()
if cfg.BaseURL != "https://my.gitlab.example.com" {
t.Errorf("trailing slash not trimmed: %q", cfg.BaseURL)
}
if cfg.Token != "glpat-test" || cfg.Source != TokenPrivate {
t.Errorf("token: %q source: %v", cfg.Token, cfg.Source)
}
}
func TestAutoConfig_CIJobToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Setenv("CI_JOB_TOKEN", "job-token-123")
defer os.Unsetenv("CI_JOB_TOKEN")
cfg := AutoConfig()
if cfg.Token != "job-token-123" || cfg.Source != TokenJobToken {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_PrivateToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Unsetenv("CI_JOB_TOKEN")
os.Setenv("GITLAB_PRIVATE_TOKEN", "legacy-token")
defer os.Unsetenv("GITLAB_PRIVATE_TOKEN")
cfg := AutoConfig()
if cfg.Token != "legacy-token" || cfg.Source != TokenPrivate {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_GitlabURL(t *testing.T) {
os.Unsetenv("CI_SERVER_URL")
os.Setenv("GITLAB_URL", "https://gl.local")
defer os.Unsetenv("GITLAB_URL")
cfg := AutoConfig()
if cfg.BaseURL != "https://gl.local" {
t.Errorf("unexpected BaseURL: %q", cfg.BaseURL)
}
}
// ── WithOverrides ─────────────────────────────────────────────────────────────
func TestWithOverrides(t *testing.T) {
base := GitLabConfig{BaseURL: "https://gitlab.com", Token: "old", Source: TokenPrivate}
got := base.WithOverrides("https://other.com/", "new-token", "/cache", true)
if got.BaseURL != "https://other.com" { t.Errorf("BaseURL: %q", got.BaseURL) }
if got.Token != "new-token" { t.Error("Token not overridden") }
if got.CacheDir != "/cache" { t.Error("CacheDir not set") }
if !got.Offline { t.Error("Offline not set") }
// empty string leaves BaseURL alone
got2 := base.WithOverrides("", "", "", false)
if got2.BaseURL != "https://gitlab.com" { t.Error("empty override should not change BaseURL") }
}
// ── ForHost ───────────────────────────────────────────────────────────────────
func TestForHost(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com"}
got := cfg.ForHost("my-host.example.com")
if got.BaseURL != "https://my-host.example.com" {
t.Errorf("unexpected BaseURL: %q", got.BaseURL)
}
}
// ── HasToken ──────────────────────────────────────────────────────────────────
func TestHasToken(t *testing.T) {
if (GitLabConfig{}).HasToken() { t.Error("empty config should have no token") }
if !(GitLabConfig{Token: "x"}).HasToken() { t.Error("config with token should have token") }
}
// ── FetchFile ─────────────────────────────────────────────────────────────────
func TestFetchFile_FromCache(t *testing.T) {
dir := t.TempDir()
key := "https://gitlab.com|my/project|/templates/ci.yml|HEAD"
cacheWrite(dir, key, []byte("cached: true"))
cfg := GitLabConfig{BaseURL: "https://gitlab.com", CacheDir: dir}
data, err := cfg.FetchFile("my/project", "/templates/ci.yml", "")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "cached: true" { t.Errorf("got %q", data) }
}
func TestFetchFile_Offline_Miss(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com", Offline: true}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error in offline mode") }
}
func TestFetchFile_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("script: echo ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
data, err := cfg.FetchFile("ns/proj", "/ci.yml", "main")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "script: echo ok" { t.Errorf("got %q", data) }
}
func TestFetchFile_Unauthorized_NoToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_Unauthorized_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_OtherStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_JobToken(t *testing.T) {
var gotHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("JOB-TOKEN")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "ci-job-tok", Source: TokenJobToken}
cfg.FetchFile("p/q", "/f.yml", "main")
if gotHeader != "ci-job-tok" {
t.Errorf("JOB-TOKEN header not sent, got %q", gotHeader)
}
}
func TestFetchFile_WritesToCache(t *testing.T) {
dir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("stage: build"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, CacheDir: dir}
_, err := cfg.FetchFile("p/q", "/ci.yml", "main")
if err != nil { t.Fatal(err) }
// Second call should hit cache
data, ok := cacheRead(dir, srv.URL+"|p/q|/ci.yml|main")
if !ok { t.Fatal("cache miss after fetch") }
if string(data) != "stage: build" { t.Errorf("unexpected cache content: %q", data) }
}
// ── FetchURL ──────────────────────────────────────────────────────────────────
func TestFetchURL_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("remote: content"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
data, err := cfg.FetchURL(srv.URL + "/template.yml")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "remote: content" { t.Errorf("got %q", data) }
}
func TestFetchURL_FromCache(t *testing.T) {
dir := t.TempDir()
rawURL := "https://example.com/tmpl.yml"
cacheWrite(dir, rawURL, []byte("cached"))
cfg := GitLabConfig{CacheDir: dir}
data, err := cfg.FetchURL(rawURL)
if err != nil { t.Fatal(err) }
if string(data) != "cached" { t.Errorf("got %q", data) }
}
func TestFetchURL_Offline(t *testing.T) {
cfg := GitLabConfig{Offline: true}
_, err := cfg.FetchURL("https://example.com/tmpl.yml")
if err == nil { t.Fatal("expected error in offline mode") }
}
// TestFetchFile_NewRequestFails covers gitlab.go:113-115 — http.NewRequest error
// when the BaseURL contains a control character (null byte) making the URL invalid.
func TestFetchFile_NewRequestFails(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://example.com\x00"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error for URL with null byte")
}
}
// TestFetchFile_DoFails covers gitlab.go:132-134 — http.DefaultClient.Do error.
func TestFetchFile_DoFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport error")
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when transport fails")
}
}
// TestFetchFile_ReadBodyFails covers gitlab.go:138-140 — io.ReadAll error on the
// response body.
func TestFetchFile_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when body read fails")
}
}
// TestFetchURL_GetFails covers gitlab.go:173-175 — http.Get error.
func TestFetchURL_GetFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("get failed")
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when http.Get fails")
}
}
// TestFetchURL_ReadBodyFails covers gitlab.go:178-180 — io.ReadAll error on the
// FetchURL response body.
func TestFetchURL_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when FetchURL body read fails")
}
}
func TestFetchURL_NotOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
cfg := GitLabConfig{}
_, err := cfg.FetchURL(srv.URL)
if err == nil { t.Fatal("expected error for non-200 status") }
}