diff --git a/internal/costs/costs_test.go b/internal/costs/costs_test.go index 5bfc2e6..494e068 100644 --- a/internal/costs/costs_test.go +++ b/internal/costs/costs_test.go @@ -1,5 +1,100 @@ package costs +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/runs-on/action/internal/config" + "github.com/sethvargo/go-githubactions" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestComputeAndDisplayCostsResponses(t *testing.T) { + for _, mode := range []string{"inline", "summary"} { + for _, tc := range []struct { + name string + status int + body string + transportErr error + wantErr string + }{ + {name: "unavailable", status: 204}, + {name: "available", status: 200, body: `{"instanceType":"m7i.large","region":"eu-west-3","totalCost":0.12}`}, + {name: "not found", status: 404, body: `{"error":"not found"}`, wantErr: "404 Not Found"}, + {name: "server error", status: 500, body: `{"error":"failed"}`, wantErr: "500 Internal Server Error"}, + {name: "invalid response", status: 200, body: `{`, wantErr: "failed to decode"}, + {name: "network failure", transportErr: errors.New("connection failed"), wantErr: "failed to send"}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + t.Setenv("RUNS_ON_INSTANCE_LAUNCHED_AT", "2026-01-01T00:00:00Z") + t.Setenv("RUNS_ON_AWS_REGION", "eusc-de-east-1") + t.Setenv("RUNS_ON_AWS_AZ", "") // Avoid unrelated EC2 zone discovery. + t.Setenv("RUNS_ON_INSTANCE_TYPE", "m7i.large") + summary := filepath.Join(t.TempDir(), "summary") + t.Setenv("GITHUB_STEP_SUMMARY", summary) + oldTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != costAPIURL || r.Method != http.MethodPost { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL) + } + if tc.transportErr != nil { + return nil, tc.transportErr + } + return &http.Response{StatusCode: tc.status, Status: fmt.Sprintf("%d %s", tc.status, http.StatusText(tc.status)), Body: io.NopCloser(strings.NewReader(tc.body)), Header: make(http.Header)}, nil + }) + t.Cleanup(func() { http.DefaultTransport = oldTransport }) + output, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + defer output.Close() + oldStdout := os.Stdout + os.Stdout = output + t.Cleanup(func() { os.Stdout = oldStdout }) + var logs bytes.Buffer + err = ComputeAndDisplayCosts(githubactions.New(githubactions.WithWriter(&logs)), &config.Config{ShowCosts: mode}) + os.Stdout = oldStdout + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error=%v, want %q", err, tc.wantErr) + } + } else if err != nil { + t.Fatal(err) + } + printed, _ := os.ReadFile(output.Name()) + written, _ := os.ReadFile(summary) + if tc.status == 204 { + want := "Skipping cost report: pricing data is unavailable for instance m7i.large in region eusc-de-east-1." + if !strings.Contains(logs.String(), want) || strings.Contains(logs.String(), "::warning") { + t.Fatalf("logs=%q", logs.String()) + } + } + if tc.status == 200 && tc.wantErr == "" { + if !strings.Contains(string(printed), "$0.1200") { + t.Fatalf("missing costs: %s", printed) + } + if mode == "summary" && !bytes.Equal(bytes.TrimSpace(printed), bytes.TrimSpace(written)) { + t.Fatalf("summary differs from output: %q", written) + } + } else if len(printed) != 0 || len(written) != 0 { + t.Fatalf("unexpected cost output: %q, summary: %q", printed, written) + } + }) + } + } +} +package costs + import ( "bytes" "errors" diff --git a/internal/sccache/sccache_test.go b/internal/sccache/sccache_test.go index 2c9b00b..78f49b9 100644 --- a/internal/sccache/sccache_test.go +++ b/internal/sccache/sccache_test.go @@ -131,3 +131,136 @@ func TestDefaultKeyPrefixSpellsThePlatformConsistently(t *testing.T) { t.Fatalf("DefaultKeyPrefix() = %q without platform env, want %q", got, withEnv) } } +package sccache + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/sethvargo/go-githubactions" +) + +func TestConfigureSccacheExportsResolvedPrefix(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "42") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + t.Setenv("RUNS_ON_S3_BUCKET_CACHE", "test-cache-bucket") + t.Setenv("RUNS_ON_AWS_REGION", "us-east-1") + t.Setenv("SCCACHE_S3_KEY_PREFIX", "cache/previous-invocation") + envFile := filepath.Join(t.TempDir(), "env") + t.Setenv("GITHUB_ENV", envFile) + var log bytes.Buffer + if err := ConfigureSccache(githubactions.New(githubactions.WithWriter(&log)), "s3"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + exports := make(map[string]string) + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + for i := 0; i < len(lines); i += 3 { + key, delimiter, ok := strings.Cut(lines[i], "<<") + if !ok || i+2 >= len(lines) || lines[i+2] != delimiter { + t.Fatalf("invalid environment export: %q", data) + } + exports[key] = lines[i+1] + } + for key, want := range map[string]string{ + "SCCACHE_GHA_ENABLED": "false", "SCCACHE_BUCKET": "test-cache-bucket", + "SCCACHE_REGION": "us-east-1", "SCCACHE_S3_KEY_PREFIX": "cache/sccache/42/linux-x64/v1", "RUSTC_WRAPPER": "sccache", + } { + if got := exports[key]; got != want { + t.Errorf("exported %s = %q, want %q", key, got, want) + } + } +} + +func TestDefaultKeyPrefixScopesRepositoryAndPlatform(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "123456789") + t.Setenv("GITHUB_REPOSITORY", "runs-on/action") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/123456789/linux-x64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +func TestDefaultKeyPrefixFallsBackToRepositorySlug(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", "Runs-On/Action") + t.Setenv("RUNNER_OS", "Windows") + t.Setenv("RUNNER_ARCH", "X64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/runs-on/action/windows-x64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +// The owner and the name of a repository may both contain the separator, so a +// flattened slug would hand foo-bar/baz and foo/bar-baz the same cache. +func TestDefaultKeyPrefixKeepsRepositoriesApartInTheSlugFallback(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + t.Setenv("GITHUB_REPOSITORY", "foo-bar/baz") + first := DefaultKeyPrefix() + + t.Setenv("GITHUB_REPOSITORY", "foo/bar-baz") + second := DefaultKeyPrefix() + + if first == second { + t.Fatalf("foo-bar/baz and foo/bar-baz share the prefix %q", first) + } +} + +func TestDefaultKeyPrefixStaysWellFormedWithoutRepositoryIdentity(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", "") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "ARM64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/unknown/linux-arm64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +// path.Join resolves "." and "..", so a component that survived sanitization +// could walk the generated key out of the cache/sccache namespace. +func TestDefaultKeyPrefixCannotEscapeTheNamespace(t *testing.T) { + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + for _, repository := range []string{"..", ".", "../..", "owner/.."} { + t.Run(repository, func(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", repository) + + if got := DefaultKeyPrefix(); !strings.HasPrefix(got, KeyPrefixRoot+"/") { + t.Fatalf("DefaultKeyPrefix() = %q, want a key under %q", got, KeyPrefixRoot) + } + }) + } +} + +// The Actions environment and the Go runtime spell the same platform +// differently, so an environment missing RUNNER_ARCH must not land on a second +// prefix for the machine that has it. +func TestDefaultKeyPrefixSpellsThePlatformConsistently(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "42") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + withEnv := DefaultKeyPrefix() + + t.Setenv("RUNNER_OS", "") + t.Setenv("RUNNER_ARCH", "") + if got := DefaultKeyPrefix(); got != withEnv && runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + t.Fatalf("DefaultKeyPrefix() = %q without platform env, want %q", got, withEnv) + } +}