Files
goget/cmd/goget/main_test.go
T

614 lines
19 KiB
Go

//go:build linux || freebsd
// +build linux freebsd
package main
import (
"context"
"flag"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/output"
)
func TestExtractConfigPath(t *testing.T) {
tests := []struct {
name string
args []string
expected string
}{
{"no config flag", []string{"--url", "https://example.com"}, ""},
{"--config with value", []string{"--config", "/path/to/config.json"}, "/path/to/config.json"},
{"--config=value", []string{"--config=/path/to/config.json"}, "/path/to/config.json"},
{"config after url", []string{"--url", "https://example.com", "--config", "/custom/config.json"}, "/custom/config.json"},
{"empty args", []string{}, ""},
{"--config without value", []string{"--config"}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractConfigPath(tc.args)
if result != tc.expected {
t.Errorf("extractConfigPath(%v) = %q, want %q", tc.args, result, tc.expected)
}
})
}
}
func TestParseRateLimit(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"", 0},
{"0", 0},
{"100", 100},
{"1KB/s", 1000},
{"1MB/s", 1000000},
{"1GB/s", 1000000000},
{"500KB/s", 500000},
{"10MB/s", 10000000},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result, err := parseRateLimit(tc.input)
if err != nil {
t.Errorf("parseRateLimit(%q) returned unexpected error: %v", tc.input, err)
}
if result != tc.expected {
t.Errorf("parseRateLimit(%q) = %d, want %d", tc.input, result, tc.expected)
}
})
}
}
// TestParseRateLimitInvalid is a regression guard for the BACKLOG entry
// "`parseRateLimit` silently returns 0 on parse error". The previous
// implementation fell back to 0 without telling the caller, so a typo
// like "1oMB/s" (letter 'o' instead of digit '0') silently disabled rate
// limiting. The fixed implementation returns a non-nil error so the caller
// can warn the user.
func TestParseRateLimitInvalid(t *testing.T) {
tests := []struct {
name string
input string
}{
{"letter_o_instead_of_zero", "1oMB/s"},
{"pure_garbage", "abc"},
{"trailing_unit_only", "MB"},
{"only_unit_letter", "B"},
{"whitespace_around_digits", " abc "},
{"multiple_dots", "1.2.3MB"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseRateLimit(tc.input)
if err == nil {
t.Errorf("parseRateLimit(%q) = %d, nil err; want non-nil error", tc.input, result)
}
if result != 0 {
t.Errorf("parseRateLimit(%q) = %d on error, want 0", tc.input, result)
}
})
}
}
func TestExtractURLs(t *testing.T) {
tests := []struct {
name string
args []string
expected int
}{
{"single url", []string{"--url", "https://example.com/file.zip"}, 1},
{"multiple urls", []string{"--url", "https://a.com", "--url", "https://b.com"}, 2},
{"with other flags", []string{"--url", "https://a.com", "--verbose", "--url", "https://b.com"}, 2},
{"url= format", []string{"--url=https://a.com", "--url=https://b.com"}, 2},
{"no url", []string{"--verbose", "--debug"}, 0},
{"empty args", []string{}, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractURLs(tc.args)
if len(result) != tc.expected {
t.Errorf("extractURLs(%v) returned %d URLs, want %d", tc.args, len(result), tc.expected)
}
})
}
}
func TestBatchFileURLParsing(t *testing.T) {
// Test that the URL parsing logic in handleBatchFile would work
// by testing the individual steps
content := `https://example.com/file1.zip
https://example.com/file2.zip
# This is a comment
// This is also a comment
https://example.com/file3.zip`
lines := strings.Split(strings.TrimSpace(content), "\n")
var urls []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
urls = append(urls, line)
}
if len(urls) != 3 {
t.Errorf("Expected 3 URLs, got %d: %v", len(urls), urls)
}
}
// TestConfigSetFilePermissions is a regression guard for the BACKLOG
// entry "os.WriteFile with 0644 on config containing OAuth tokens".
// The previous implementation used 0644, which leaves the config file
// (and any embedded access_token / refresh_token) world-readable on
// multi-user systems. After the fix, both handleConfigSet and
// handleConfigUnset must write the file with mode 0600.
func TestConfigFilePermissions(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T, configPath string)
}{
{
name: "handleConfigSet writes 0600",
run: func(t *testing.T, configPath string) {
if rc := handleConfigSet(configPath, "timeout", []string{
"--config-set", "timeout", "30",
}); rc != 0 {
t.Fatalf("handleConfigSet returned %d", rc)
}
},
},
{
name: "handleConfigUnset writes 0600",
run: func(t *testing.T, configPath string) {
if rc := handleConfigSet(configPath, "timeout", []string{
"--config-set", "timeout", "30",
}); rc != 0 {
t.Fatalf("seed handleConfigSet returned %d", rc)
}
if rc := handleConfigUnset(configPath, "timeout"); rc != 0 {
t.Fatalf("handleConfigUnset returned %d", rc)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.toml")
// Seed a minimal config so config.Load succeeds.
seed := []byte("proxy = \"\"\nuser_agent = \"\"\ntimeout = 0\n")
if err := os.WriteFile(configPath, seed, 0600); err != nil {
t.Fatalf("seed write: %v", err)
}
tc.run(t, configPath)
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
perm := info.Mode().Perm()
// Mask out the umask bits by checking against 0600; the file
// must not be group- or world-readable.
if perm != 0600 {
t.Errorf("config file mode = %04o, want 0600", perm)
}
})
}
}
func TestNextCronTime(t *testing.T) {
// Use a fixed reference time for deterministic tests (UTC)
ref, _ := time.Parse("2006-01-02 15:04", "2026-06-01 10:00")
ref = ref.UTC()
t.Run("every minute", func(t *testing.T) {
next, err := nextCronTime("* * * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := ref.Truncate(time.Minute).Add(time.Minute)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("specific hour and minute", func(t *testing.T) {
next, err := nextCronTime("30 14 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 1, 14, 30, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("daily at midnight", func(t *testing.T) {
next, err := nextCronTime("0 0 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("specific day of week", func(t *testing.T) {
// June 1, 2026 is Monday (1)
next, err := nextCronTime("0 9 * * 3", ref) // Wednesday
if err != nil {
t.Fatal(err)
}
if next.Weekday() != time.Wednesday {
t.Errorf("expected Wednesday, got %v", next.Weekday())
}
})
t.Run("comma separated hours", func(t *testing.T) {
next, err := nextCronTime("0 8,16 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 1, 16, 0, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("step values", func(t *testing.T) {
next, err := nextCronTime("*/15 * * * *", ref)
if err != nil {
t.Fatal(err)
}
if next.Minute()%15 != 0 {
t.Errorf("minute not a multiple of 15: %d", next.Minute())
}
})
t.Run("invalid fields", func(t *testing.T) {
_, err := nextCronTime("invalid", ref)
if err == nil {
t.Error("expected error for invalid cron expression")
}
})
t.Run("too many fields", func(t *testing.T) {
_, err := nextCronTime("1 2 3 4 5 6", ref)
if err == nil {
t.Error("expected error for 6 fields")
}
})
}
// TestExitCodeForRun covers the contract that graceful shutdown
// surfaces exit code 130 to the shell when the user interrupts the
// process. A clean exit (no signal) returns the inner code unchanged;
// a non-zero inner code (real error) is preserved even if the signal
// context was also cancelled.
func TestExitCodeForRun(t *testing.T) {
t.Run("clean exit on no signal returns inner code 0", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if got := exitCodeForRun(ctx, 0); got != 0 {
t.Errorf("got %d, want 0", got)
}
})
t.Run("cancelled signal context maps clean exit to 130", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // simulate SIGINT/SIGTERM propagation
if got := exitCodeForRun(ctx, 0); got != 130 {
t.Errorf("got %d, want 130", got)
}
})
t.Run("non-zero inner code is preserved when signal is also cancelled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Even with the signal context cancelled, a real error (e.g.
// download failure) should not be masked by the 130 mapping.
if got := exitCodeForRun(ctx, 7); got != 7 {
t.Errorf("got %d, want 7 (inner code preserved)", got)
}
})
t.Run("non-zero inner code is returned unchanged with no signal", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if got := exitCodeForRun(ctx, 42); got != 42 {
t.Errorf("got %d, want 42", got)
}
})
t.Run("nil context returns inner code unchanged", func(t *testing.T) {
if got := exitCodeForRun(context.TODO(), 5); got != 5 {
t.Errorf("got %d, want 5 (nil context safe)", got)
}
})
}
// TestCreateDirsFlagRegisteredAndDefaults is a regression guard for the
// BACKLOG entry "Add --create-dirs flag (curl / wget compatibility)". It
// verifies the flag is registered on the command-line FlagSet, defaults to
// true (matching curl / wget behaviour so common `goget --output
// ./downloads/file.zip` invocations just work), and can be flipped to
// false via the standard flag parser. We do not exercise the full
// download path here — that is covered by TestNewWriterCreateDirsMissingParent
// in internal/output/writer_test.go.
func TestCreateDirsFlagRegisteredAndDefaults(t *testing.T) {
cfg := config.DefaultConfig()
fs := newTestFlagSet("goget-test")
flags := defineFlags(fs, cfg)
if flags.CreateDirs == nil {
t.Fatal("CreateDirs flag was not registered on the FlagSet")
}
if !*flags.CreateDirs {
t.Errorf("CreateDirs default = false, want true (curl / wget parity)")
}
// Flip the flag through the standard flag parser to make sure the
// long name is wired up correctly and the value reaches the struct.
fs2 := newTestFlagSet("goget-test-2")
flags2 := defineFlags(fs2, cfg)
if err := fs2.Parse([]string{"--create-dirs=false"}); err != nil {
t.Fatalf("Parse --create-dirs=false: %v", err)
}
if *flags2.CreateDirs {
t.Errorf("CreateDirs after --create-dirs=false = true, want false")
}
}
// newTestFlagSet builds an isolated flag.FlagSet for CLI tests. We use
// flag.ContinueOnError so a bad flag in a test fails the test instead of
// calling os.Exit and tearing down the rest of the test binary.
func newTestFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
}
// TestShowMetadataFlagRegistered is a regression guard for the BACKLOG entry
// "`--show-metadata` — display .goget.meta contents for debugging
// interrupted/resumed downloads". It verifies that the flag is wired up on
// the FlagSet, defaults to an empty string (the handler short-circuits on the
// empty value, so the flag is opt-in), and can be set via the standard
// flag parser so the long name reaches the dispatch layer.
func TestShowMetadataFlagRegistered(t *testing.T) {
cfg := config.DefaultConfig()
fs := newTestFlagSet("goget-test-show-metadata")
flags := defineFlags(fs, cfg)
if flags.ShowMetadata == nil {
t.Fatal("ShowMetadata flag was not registered on the FlagSet")
}
if *flags.ShowMetadata != "" {
t.Errorf("ShowMetadata default = %q, want empty (opt-in)", *flags.ShowMetadata)
}
fs2 := newTestFlagSet("goget-test-show-metadata-2")
flags2 := defineFlags(fs2, cfg)
if err := fs2.Parse([]string{"--show-metadata=/tmp/file.zip"}); err != nil {
t.Fatalf("Parse --show-metadata: %v", err)
}
if want := "/tmp/file.zip"; *flags2.ShowMetadata != want {
t.Errorf("ShowMetadata after parse = %q, want %q", *flags2.ShowMetadata, want)
}
}
// TestSafeMetadataURL ensures credentials embedded in resume metadata URLs
// are masked before the value is shown on the terminal. The .goget.meta
// sidecar may contain auth tokens in the query string; printing them raw
// during a debug session would defeat the 0600 file permissions.
func TestSafeMetadataURL(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no credentials", "https://example.com/file.zip", "https://example.com/file.zip"},
{"with password", "https://user:secret@example.com/file.zip", "https://user:xxxxx@example.com/file.zip"},
{"with token query", "https://example.com/file.zip?sig=abc", "https://example.com/file.zip?sig=abc"},
{"unparsable passthrough", "://broken", "://broken"},
{"username only", "https://user@example.com/file.zip", "https://user@example.com/file.zip"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := safeMetadataURL(tc.in); got != tc.want {
t.Errorf("safeMetadataURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestPercent guards the displayed progress math in --show-metadata: a zero
// or negative total (e.g. server did not advertise Content-Length) must not
// produce a confusing "Inf%" or "NaN%" reading.
func TestPercent(t *testing.T) {
tests := []struct {
name string
value int64
total int64
wantMin float64
wantMax float64
}{
{"zero total", 100, 0, 0, 0},
{"negative total", 100, -1, 0, 0},
{"full", 1000, 1000, 100, 100},
{"half", 500, 1000, 50, 50},
{"quarter", 250, 1000, 25, 25},
{"empty value", 0, 1000, 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := percent(tc.value, tc.total)
if got < tc.wantMin || got > tc.wantMax {
t.Errorf("percent(%d, %d) = %v, want in [%v, %v]", tc.value, tc.total, got, tc.wantMin, tc.wantMax)
}
})
}
}
// captureStdoutStderr swaps os.Stdout and os.Stderr for pipes and returns a
// drain function that closes the write ends, copies everything written
// through the pipes into a buffer, and restores the original os.Stdout /
// os.Stderr. Callers run the code under test, then invoke drain (typically
// via defer) and read the returned string to assert against the rendered
// output. Synchronous drain is intentional — every entry point we exercise
// is well-bounded and we want deterministic test output.
func captureStdoutStderr(t *testing.T) func() string {
t.Helper()
origStdout, origStderr := os.Stdout, os.Stderr
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("stderr pipe: %v", err)
}
os.Stdout = stdoutW
os.Stderr = stderrW
var buf strings.Builder
return func() string {
_ = stdoutW.Close()
_ = stderrW.Close()
_, _ = io.Copy(&buf, stdoutR)
_, _ = io.Copy(&buf, stderrR)
_ = stdoutR.Close()
_ = stderrR.Close()
os.Stdout = origStdout
os.Stderr = origStderr
return buf.String()
}
}
// TestHandleShowMetadata covers the four code paths of --show-metadata: a
// path pointing at the output file (the handler appends .goget.meta), a path
// pointing directly at the sidecar, a missing file (exit 1), and the empty
// argument guard. Each subtest also captures stdout + stderr via a pipe so
// the rendered summary is checked for the fields the user actually sees.
func TestHandleShowMetadata(t *testing.T) {
dir := t.TempDir()
meta := output.NewResumeMetadata(
"https://user:secret@example.com/large.bin",
"etag-abc",
"Thu, 01 Jan 2026 00:00:00 GMT",
4096, 16384,
)
outputPath := filepath.Join(dir, "large.bin")
if err := meta.Save(outputPath); err != nil {
t.Fatalf("seed save: %v", err)
}
tests := []struct {
name string
pathArg string
asJSON bool
wantExit int
wantInOut []string
wantNotOut []string
}{
{
name: "output path appends suffix",
pathArg: outputPath,
asJSON: false,
wantExit: 0,
wantInOut: []string{"URL: https://user:xxxxx@example.com/large.bin", "ETag: etag-abc", "Last-Modified: Thu, 01 Jan 2026", "Progress: 25.0%", "Version: 1.0"},
// Raw secret must not leak through the password-masking helper.
wantNotOut: []string{"secret"},
},
{
name: "direct sidecar path",
pathArg: outputPath + output.ResumeMetaFileSuffix,
asJSON: false,
wantExit: 0,
wantInOut: []string{"Path: " + outputPath + output.ResumeMetaFileSuffix, "Downloaded: 4.1 KB (4096 bytes)"},
},
{
name: "json mode prints raw bytes",
pathArg: outputPath,
asJSON: true,
wantExit: 0,
wantInOut: []string{`"etag": "etag-abc"`, `"downloaded": 4096`, `"total": 16384`},
// Raw bytes contain the password — that's intentional, the user
// opted into JSON mode and the file is chmod 0600.
wantNotOut: nil,
},
{
name: "missing file returns 1",
pathArg: filepath.Join(dir, "never-downloaded.bin"),
asJSON: false,
wantExit: 1,
wantInOut: []string{"no resume metadata found at"},
},
{
name: "empty argument returns 1",
pathArg: "",
asJSON: false,
wantExit: 1,
wantInOut: []string{"--show-metadata requires a path"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
drain := captureStdoutStderr(t)
if got := handleShowMetadata(tc.pathArg, tc.asJSON); got != tc.wantExit {
t.Errorf("handleShowMetadata exit = %d, want %d", got, tc.wantExit)
}
got := drain()
for _, want := range tc.wantInOut {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\nfull output:\n%s", want, got)
}
}
for _, banned := range tc.wantNotOut {
if strings.Contains(got, banned) {
t.Errorf("output must not contain %q\nfull output:\n%s", banned, got)
}
}
})
}
}
// TestHandleShowMetadataCorruptJSON ensures a malformed .goget.meta sidecar
// does not produce a silent success. The user must see the raw bytes so they
// can diagnose why --resume later fails to parse the same file.
func TestHandleShowMetadataCorruptJSON(t *testing.T) {
dir := t.TempDir()
outputPath := filepath.Join(dir, "broken.bin")
metaPath := outputPath + output.ResumeMetaFileSuffix
if err := os.WriteFile(metaPath, []byte("{not valid json"), 0600); err != nil {
t.Fatalf("seed write: %v", err)
}
drain := captureStdoutStderr(t)
if got := handleShowMetadata(outputPath, false); got != 0 {
t.Errorf("handleShowMetadata exit = %d, want 0 (still attempt to read)", got)
}
out := drain()
if !strings.Contains(out, "{not valid json") {
t.Errorf("expected raw metadata in output, got: %s", out)
}
if !strings.Contains(out, "not valid JSON") {
t.Errorf("expected warning about corrupt JSON, got: %s", out)
}
}