Files
gh-action-readme/internal/dependencies/analyzer_test.go
Ismo Vuorinen 3fbb608f9f feat: update go version, renovate config, tooling, fixes (#28)
* feat(deps): update go version, renovate config, tooling

* chore(deps): update google/go-github to v74

* feat(deps): migrate from yaml.v3 to goccy/go-yaml

* chore(deps): update goccy/go-yaml to v1.18.0 and address security concerns

* feat: improve issue templates and project configuration

- Update GitHub issue templates with CLI-specific fields for better bug reports
- Add specialized templates for documentation, theme, and performance issues
- Update pre-commit config to include comprehensive documentation linting
- Remove outdated Snyk configuration and security references
- Update Go version from 1.23+ to 1.24+ across project
- Streamline README.md organization and improve clarity
- Update CHANGELOG.md and CLAUDE.md formatting
- Create comprehensive CONTRIBUTING.md with development guidelines
- Remove TODO.md (replaced by docs/roadmap.md)
- Move SECURITY.md to docs/security.md

* docs: fix markdown linting violations across documentation

* fix: resolve template placeholder issues and improve uses statement generation

* fix: remove trailing whitespace from GitHub issue template
2025-08-07 05:22:44 +03:00

656 lines
17 KiB
Go

package dependencies
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
actionYML string
expectError bool
expectDeps bool
expectedLen int
expectedDeps []string
}{
{
name: "simple action - no dependencies",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "composite action with dependencies",
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
expectError: false,
expectDeps: true,
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"},
},
{
name: "docker action - no step dependencies",
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "invalid action file",
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
expectError: true,
},
{
name: "minimal action - no dependencies",
actionYML: testutil.MustReadFixture("minimal-action.yml"),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Create temporary action file
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, tt.actionYML)
// Create analyzer with mock GitHub client
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: NewCacheAdapter(cacheInstance),
}
// Analyze the action file
deps, err := analyzer.AnalyzeActionFile(actionPath)
// Check error expectation
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
// Check dependencies
if tt.expectDeps {
if len(deps) != tt.expectedLen {
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
}
// Check specific dependencies if provided
if tt.expectedDeps != nil {
for i, expectedDep := range tt.expectedDeps {
if i >= len(deps) {
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
continue
}
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
}
}
}
} else if len(deps) != 0 {
t.Errorf("expected no dependencies, got %d", len(deps))
}
})
}
}
func TestAnalyzer_ParseUsesStatement(t *testing.T) {
t.Parallel()
tests := []struct {
name string
uses string
expectedOwner string
expectedRepo string
expectedVersion string
expectedType VersionType
}{
{
name: "semantic version",
uses: "actions/checkout@v4",
expectedOwner: "actions",
expectedRepo: "checkout",
expectedVersion: "v4",
expectedType: SemanticVersion,
},
{
name: "semantic version with patch",
uses: "actions/setup-node@v3.8.1",
expectedOwner: "actions",
expectedRepo: "setup-node",
expectedVersion: "v3.8.1",
expectedType: SemanticVersion,
},
{
name: "commit SHA",
uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectedOwner: "actions",
expectedRepo: "checkout",
expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectedType: CommitSHA,
},
{
name: "branch reference",
uses: "octocat/hello-world@main",
expectedOwner: "octocat",
expectedRepo: "hello-world",
expectedVersion: "main",
expectedType: BranchName,
},
}
analyzer := &Analyzer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
owner, repo, version, versionType := analyzer.parseUsesStatement(tt.uses)
testutil.AssertEqual(t, tt.expectedOwner, owner)
testutil.AssertEqual(t, tt.expectedRepo, repo)
testutil.AssertEqual(t, tt.expectedVersion, version)
testutil.AssertEqual(t, tt.expectedType, versionType)
})
}
}
func TestAnalyzer_VersionChecking(t *testing.T) {
t.Parallel()
tests := []struct {
name string
version string
isPinned bool
isCommitSHA bool
isSemantic bool
}{
{
name: "semantic version major",
version: "v4",
isPinned: false,
isCommitSHA: false,
isSemantic: true,
},
{
name: "semantic version full",
version: "v3.8.1",
isPinned: true,
isCommitSHA: false,
isSemantic: true,
},
{
name: "commit SHA full",
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
isPinned: true,
isCommitSHA: true,
isSemantic: false,
},
{
name: "commit SHA short",
version: "8f4b7f8",
isPinned: false,
isCommitSHA: true,
isSemantic: false,
},
{
name: "branch reference",
version: "main",
isPinned: false,
isCommitSHA: false,
isSemantic: false,
},
{
name: "numeric version",
version: "1.2.3",
isPinned: true,
isCommitSHA: false,
isSemantic: true,
},
}
analyzer := &Analyzer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
isPinned := analyzer.isVersionPinned(tt.version)
isCommitSHA := analyzer.isCommitSHA(tt.version)
isSemantic := analyzer.isSemanticVersion(tt.version)
testutil.AssertEqual(t, tt.isPinned, isPinned)
testutil.AssertEqual(t, tt.isCommitSHA, isCommitSHA)
testutil.AssertEqual(t, tt.isSemantic, isSemantic)
})
}
}
func TestAnalyzer_GetLatestVersion(t *testing.T) {
t.Parallel()
// Create mock GitHub client with test responses
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
tests := []struct {
name string
owner string
repo string
expectedVersion string
expectedSHA string
expectError bool
}{
{
name: "valid repository",
owner: "actions",
repo: "checkout",
expectedVersion: "v4.1.1",
expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectError: false,
},
{
name: "another valid repository",
owner: "actions",
repo: "setup-node",
expectedVersion: "v4.0.0",
expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
version, sha, err := analyzer.getLatestVersion(tt.owner, tt.repo)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
testutil.AssertEqual(t, tt.expectedVersion, version)
testutil.AssertEqual(t, tt.expectedSHA, sha)
})
}
}
func TestAnalyzer_CheckOutdated(t *testing.T) {
t.Parallel()
// Create mock GitHub client
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// Create test dependencies
dependencies := []Dependency{
{
Name: "actions/checkout",
Uses: "actions/checkout@v3",
Version: "v3",
IsPinned: false,
VersionType: SemanticVersion,
Description: "Action for checking out a repo",
},
{
Name: "actions/setup-node",
Uses: "actions/setup-node@v4.0.0",
Version: "v4.0.0",
IsPinned: true,
VersionType: SemanticVersion,
Description: "Setup Node.js",
},
}
outdated, err := analyzer.CheckOutdated(dependencies)
testutil.AssertNoError(t, err)
// Should detect that actions/checkout v3 is outdated (latest is v4.1.1)
if len(outdated) == 0 {
t.Error("expected to find outdated dependencies")
}
found := false
for _, dep := range outdated {
if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" {
found = true
if dep.LatestVersion != "v4.1.1" {
t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion)
}
if dep.UpdateType != "major" {
t.Errorf("expected major update, got %s", dep.UpdateType)
}
}
}
if !found {
t.Error("expected to find actions/checkout v3 as outdated")
}
}
func TestAnalyzer_CompareVersions(t *testing.T) {
t.Parallel()
analyzer := &Analyzer{}
tests := []struct {
name string
current string
latest string
expectedType string
}{
{
name: "major version difference",
current: "v3.0.0",
latest: "v4.0.0",
expectedType: "major",
},
{
name: "minor version difference",
current: "v4.0.0",
latest: "v4.1.0",
expectedType: "minor",
},
{
name: "patch version difference",
current: "v4.1.0",
latest: "v4.1.1",
expectedType: "patch",
},
{
name: "no difference",
current: "v4.1.1",
latest: "v4.1.1",
expectedType: "none",
},
{
name: "floating to specific",
current: "v4",
latest: "v4.1.1",
expectedType: "patch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updateType := analyzer.compareVersions(tt.current, tt.latest)
testutil.AssertEqual(t, tt.expectedType, updateType)
})
}
}
func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
t.Parallel()
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Create a test action file with composite steps
actionContent := testutil.MustReadFixture("test-composite-action.yml")
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, actionContent)
// Create analyzer
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// Create test dependency
dep := Dependency{
Name: "actions/checkout",
Uses: "actions/checkout@v3",
Version: "v3",
IsPinned: false,
VersionType: SemanticVersion,
Description: "Action for checking out a repo",
}
// Generate pinned update
update, err := analyzer.GeneratePinnedUpdate(
actionPath,
dep,
"v4.1.1",
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
)
testutil.AssertNoError(t, err)
// Verify update details
testutil.AssertEqual(t, actionPath, update.FilePath)
testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses)
testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e")
testutil.AssertStringContains(t, update.NewUses, "# v4.1.1")
testutil.AssertEqual(t, "major", update.UpdateType)
}
func TestAnalyzer_WithCache(t *testing.T) {
t.Parallel()
// Test that caching works properly
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// First call should hit the API
version1, sha1, err1 := analyzer.getLatestVersion("actions", "checkout")
testutil.AssertNoError(t, err1)
// Second call should hit the cache
version2, sha2, err2 := analyzer.getLatestVersion("actions", "checkout")
testutil.AssertNoError(t, err2)
// Results should be identical
testutil.AssertEqual(t, version1, version2)
testutil.AssertEqual(t, sha1, sha2)
}
func TestAnalyzer_RateLimitHandling(t *testing.T) {
t.Parallel()
// Create mock client that returns rate limit error
rateLimitResponse := &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{
"X-RateLimit-Remaining": []string{"0"},
"X-RateLimit-Reset": []string{strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)},
},
Body: testutil.NewStringReader(`{"message": "API rate limit exceeded"}`),
}
mockClient := &testutil.MockHTTPClient{
Responses: map[string]*http.Response{
"GET https://api.github.com/repos/actions/checkout/releases/latest": rateLimitResponse,
},
}
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: client,
Cache: cacheInstance,
}
// This should handle the rate limit gracefully
_, _, err := analyzer.getLatestVersion("actions", "checkout")
if err == nil {
t.Error("expected rate limit error to be returned")
}
// The error message depends on GitHub client implementation
// It should fail with either rate limit or API error
if !strings.Contains(err.Error(), "rate limit") && !strings.Contains(err.Error(), "no releases or tags found") {
t.Errorf("expected error to contain rate limit info or no releases message, got: %s", err.Error())
}
}
func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
t.Parallel()
// Test graceful degradation when GitHub client is not available
analyzer := &Analyzer{
GitHubClient: nil,
Cache: nil,
}
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
deps, err := analyzer.AnalyzeActionFile(actionPath)
// Should still parse dependencies but without GitHub API data
testutil.AssertNoError(t, err)
if len(deps) > 0 {
// Dependencies should have basic info but no GitHub API data
for _, dep := range deps {
// Only check action dependencies (not shell scripts which have hardcoded descriptions)
if !dep.IsShellScript && dep.Description != "" {
t.Error("expected empty description when GitHub client is not available")
}
}
}
}
// mockTransport wraps our mock HTTP client for GitHub client.
type mockTransport struct {
client *testutil.MockHTTPClient
}
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.client.Do(req)
}
// TestNewAnalyzer tests the analyzer constructor.
func TestNewAnalyzer(t *testing.T) {
t.Parallel()
// Create test dependencies
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
testutil.AssertNoError(t, err)
defer func() { _ = cacheInstance.Close() }()
repoInfo := git.RepoInfo{
Organization: "test-owner",
Repository: "test-repo",
}
tests := []struct {
name string
client *github.Client
repoInfo git.RepoInfo
cache DependencyCache
expectNotNil bool
}{
{
name: "creates analyzer with all dependencies",
client: githubClient,
repoInfo: repoInfo,
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
{
name: "creates analyzer with nil client",
client: nil,
repoInfo: repoInfo,
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
{
name: "creates analyzer with nil cache",
client: githubClient,
repoInfo: repoInfo,
cache: nil,
expectNotNil: true,
},
{
name: "creates analyzer with empty repo info",
client: githubClient,
repoInfo: git.RepoInfo{},
cache: NewCacheAdapter(cacheInstance),
expectNotNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
analyzer := NewAnalyzer(tt.client, tt.repoInfo, tt.cache)
if tt.expectNotNil && analyzer == nil {
t.Fatal("expected non-nil analyzer")
}
// Verify fields are set correctly
if analyzer.GitHubClient != tt.client {
t.Error("GitHub client not set correctly")
}
if analyzer.Cache != tt.cache {
t.Error("cache not set correctly")
}
if analyzer.RepoInfo != tt.repoInfo {
t.Error("repo info not set correctly")
}
})
}
}