Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions internal/costs/costs_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
133 changes: 133 additions & 0 deletions internal/sccache/sccache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}