Pulse/internal/dockeragent/registry_http_test.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

290 lines
7.4 KiB
Go

package dockeragent
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/rs/zerolog"
)
func TestRegistryChecker_CheckImageUpdate_Behavior(t *testing.T) {
logger := zerolog.Nop()
t.Run("disabled checker returns nil", func(t *testing.T) {
checker := NewRegistryChecker(logger)
checker.SetEnabled(false)
result := checker.CheckImageUpdate(context.Background(), "nginx:latest", "sha256:current", "", "", "")
if result != nil {
t.Error("Expected nil result when checker is disabled")
}
})
t.Run("digest-pinned image skipped", func(t *testing.T) {
checker := NewRegistryChecker(logger)
result := checker.CheckImageUpdate(context.Background(), "nginx@sha256:abc123", "sha256:abc123", "", "", "")
if result == nil {
t.Fatal("Expected result for digest-pinned image")
}
if result.UpdateAvailable {
t.Error("Expected no update available for digest-pinned image")
}
if result.Error != "digest-pinned image" {
t.Errorf("Expected error 'digest-pinned image', got %q", result.Error)
}
})
t.Run("empty image name", func(t *testing.T) {
checker := NewRegistryChecker(logger)
checker.httpClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "auth.docker.io":
return newStringResponse(http.StatusOK, nil, `{"token":"token"}`), nil
case "registry-1.docker.io":
return newStringResponse(http.StatusNotFound, nil, ""), nil
default:
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}
}),
}
result := checker.CheckImageUpdate(context.Background(), "", "sha256:current", "", "", "")
if result == nil {
t.Fatal("Expected result for empty image")
}
if result.Error == "" {
t.Fatal("Expected error for empty image")
}
})
}
func TestRegistryChecker_Caching(t *testing.T) {
logger := zerolog.Nop()
checker := NewRegistryChecker(logger)
// Test caching behavior
cacheKey := "test-key"
t.Run("cache miss returns nil", func(t *testing.T) {
entry := checker.getCached(cacheKey)
if entry != nil {
t.Error("Expected nil for cache miss")
}
})
t.Run("cache hit for digest", func(t *testing.T) {
checker.cacheDigest(cacheKey, "sha256:testdigest")
entry := checker.getCached(cacheKey)
if entry == nil {
t.Fatal("Expected cache hit")
}
if entry.latestDigest != "sha256:testdigest" {
t.Errorf("Expected digest 'sha256:testdigest', got %q", entry.latestDigest)
}
})
t.Run("cache hit for error", func(t *testing.T) {
errorKey := "error-key"
checker.cacheError(errorKey, "test error")
entry := checker.getCached(errorKey)
if entry == nil {
t.Fatal("Expected cache hit for error")
}
if entry.err != "test error" {
t.Errorf("Expected error 'test error', got %q", entry.err)
}
})
t.Run("cleanup removes expired entries", func(t *testing.T) {
// Add an expired entry manually
checker.cache.mu.Lock()
checker.cache.entries["expired-key"] = cacheEntry{
latestDigest: "sha256:old",
expiresAt: time.Now().Add(-1 * time.Hour), // Already expired
}
checker.cache.mu.Unlock()
checker.CleanupCache()
entry := checker.getCached("expired-key")
if entry != nil {
t.Error("Expected expired entry to be removed")
}
})
}
func TestRegistryChecker_ShouldCheck(t *testing.T) {
logger := zerolog.Nop()
checker := NewRegistryChecker(logger)
t.Run("should check when never checked", func(t *testing.T) {
if !checker.ShouldCheck() {
t.Error("Expected ShouldCheck to return true when never checked")
}
})
t.Run("should not check immediately after mark", func(t *testing.T) {
checker.MarkChecked()
if checker.ShouldCheck() {
t.Error("Expected ShouldCheck to return false immediately after MarkChecked")
}
})
t.Run("should not check when disabled", func(t *testing.T) {
checker.SetEnabled(false)
if checker.ShouldCheck() {
t.Error("Expected ShouldCheck to return false when disabled")
}
checker.SetEnabled(true)
})
}
func TestParseImageReference_EdgeCases(t *testing.T) {
tests := []struct {
name string
image string
wantReg string
wantRepo string
wantTag string
}{
{
name: "quay.io image",
image: "quay.io/prometheus/prometheus:v2.45.0",
wantReg: "quay.io",
wantRepo: "prometheus/prometheus",
wantTag: "v2.45.0",
},
{
name: "ecr image",
image: "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:prod",
wantReg: "123456789.dkr.ecr.us-east-1.amazonaws.com",
wantRepo: "myapp",
wantTag: "prod",
},
{
name: "gcr.io image",
image: "gcr.io/google-containers/pause:3.2",
wantReg: "gcr.io",
wantRepo: "google-containers/pause",
wantTag: "3.2",
},
{
name: "multi-level path",
image: "registry.example.com/org/team/project/app:v1",
wantReg: "registry.example.com",
wantRepo: "org/team/project/app",
wantTag: "v1",
},
{
name: "image with sha256 in name (not pinned)",
image: "myimage-sha256:latest",
wantReg: "registry-1.docker.io",
wantRepo: "library/myimage-sha256",
wantTag: "latest",
},
{
name: "mcr.io image",
image: "mcr.microsoft.com/dotnet/sdk:7.0",
wantReg: "mcr.microsoft.com",
wantRepo: "dotnet/sdk",
wantTag: "7.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotReg, gotRepo, gotTag := parseImageReference(tt.image)
if gotReg != tt.wantReg {
t.Errorf("registry = %q, want %q", gotReg, tt.wantReg)
}
if gotRepo != tt.wantRepo {
t.Errorf("repository = %q, want %q", gotRepo, tt.wantRepo)
}
if gotTag != tt.wantTag {
t.Errorf("tag = %q, want %q", gotTag, tt.wantTag)
}
})
}
}
func TestImageUpdateResult_Fields(t *testing.T) {
result := ImageUpdateResult{
Image: "nginx:latest",
CurrentDigest: "sha256:current",
LatestDigest: "sha256:latest",
UpdateAvailable: true,
CheckedAt: time.Now(),
Error: "",
}
if !result.UpdateAvailable {
t.Error("Expected UpdateAvailable to be true")
}
if result.Image != "nginx:latest" {
t.Errorf("Expected image 'nginx:latest', got %q", result.Image)
}
}
func BenchmarkParseImageReference(b *testing.B) {
images := []string{
"nginx",
"nginx:latest",
"myrepo/myapp:v1",
"ghcr.io/owner/repo:tag",
"registry.example.com:5000/app:v2",
"nginx@sha256:abc123def456",
}
for i := 0; i < b.N; i++ {
for _, img := range images {
parseImageReference(img)
}
}
}
func BenchmarkDigestsDiffer(b *testing.B) {
checker := &RegistryChecker{}
current := "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
latest := "sha256:f6e5d4c3b2a1987654321098765432109876543210987654321098765432fedc"
for i := 0; i < b.N; i++ {
checker.digestsDiffer(current, latest)
}
}
// TestConcurrentCacheAccess verifies thread-safety of the cache
func TestConcurrentCacheAccess(t *testing.T) {
logger := zerolog.Nop()
checker := NewRegistryChecker(logger)
// Spawn multiple goroutines accessing the cache
done := make(chan bool)
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
checker.cacheDigest(key, fmt.Sprintf("digest-%d", j))
checker.getCached(key)
}
done <- true
}(i)
}
// Also run cleanup concurrently
go func() {
for i := 0; i < 50; i++ {
checker.CleanupCache()
time.Sleep(time.Millisecond)
}
done <- true
}()
// Wait for all goroutines
for i := 0; i < 11; i++ {
<-done
}
}