b21ef5c0bb
- 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>
342 lines
12 KiB
Go
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") }
|
|
}
|