diff --git a/internal/testutils/README.md b/internal/testutils/README.md index 05235a38..8f757737 100644 --- a/internal/testutils/README.md +++ b/internal/testutils/README.md @@ -10,8 +10,7 @@ internal/testutils/ ├── setup.go # legacy setup helpers (compatibility) ├── helpers/ │ ├── helpers.go # core test helper functions -│ ├── fuzz.go # fuzz data generation utilities -│ └── updater.go # updater-specific test helpers +│ └── fuzz.go # fuzz data generation utilities ├── rsapss/ # RSA-PSS signing utilities ├── signer/ # generic signing test utilities └── simulator/ @@ -84,14 +83,6 @@ gen.CreateFuzzTestMetadata(metadataType string) []byte helpers.FuzzMetadataOperations(f, func(data []byte) error { ... }) ``` -### Updater utilities (`updater.go`) - -```go -helpers.ContainsTargetWithPrefix(targets []string, prefix string) bool -helpers.AssertTargetDownloaded(t, targetDir, targetName string) -helpers.AssertTargetNotDownloaded(t, targetDir, targetName string) -``` - ## simulator package ### TestRepository diff --git a/internal/testutils/helpers/updater.go b/internal/testutils/helpers/updater.go deleted file mode 100644 index 87e4134f..00000000 --- a/internal/testutils/helpers/updater.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright 2024 The Update Framework Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License -// -// SPDX-License-Identifier: Apache-2.0 -// - -package helpers - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// UpdaterTestCase defines a single table-driven updater test scenario. -// It is intentionally decoupled from the updater package to avoid import cycles. -type UpdaterTestCase struct { - // Name is the subtest name passed to t.Run. - Name string - - // Desc is an optional human-readable description logged with t.Logf. - Desc string - - // WantErr indicates that the action under test must return a non-nil error. - WantErr bool - - // WantErrType is an error value compared with errors.Is. Only checked when - // WantErr is true. - WantErrType error - - // WantErrMsg is a substring that must appear in the error message. Only - // checked when WantErr is true. - WantErrMsg string - - // RefTime is a reference time injected into updater tests (e.g. for - // expiry testing). - RefTime time.Time - - // UseUnsafeMode enables UnsafeLocalMode for this test case. - UseUnsafeMode bool -} - -// CheckError validates error expectations for a single UpdaterTestCase. -// Call it immediately after the operation under test. -func CheckError(t *testing.T, tc UpdaterTestCase, err error) { - t.Helper() - - if tc.WantErr { - if err == nil { - t.Errorf("CheckError(%q): expected error, got nil", tc.Name) - return - } - if tc.WantErrType != nil && !errors.Is(err, tc.WantErrType) { - t.Errorf("CheckError(%q): expected error type %T, got %T: %v", - tc.Name, tc.WantErrType, err, err) - } - if tc.WantErrMsg != "" && !strings.Contains(err.Error(), tc.WantErrMsg) { - t.Errorf("CheckError(%q): expected error message containing %q, got %q", - tc.Name, tc.WantErrMsg, err.Error()) - } - } else if err != nil { - t.Errorf("CheckError(%q): unexpected error: %v", tc.Name, err) - } -} - -// AssertFilesExist asserts that each role in roles has a corresponding -// ".json" file in metadataDir. -func AssertFilesExist(t *testing.T, metadataDir string, roles []string) { - t.Helper() - for _, role := range roles { - path := filepath.Join(metadataDir, fmt.Sprintf("%s.json", role)) - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Errorf("AssertFilesExist: expected %s.json not found in %s", role, metadataDir) - } - } -} - -// AssertFilesExact asserts that metadataDir contains exactly the files named -// ".json" for each role in roles — no more, no fewer. -func AssertFilesExact(t *testing.T, metadataDir string, roles []string) { - t.Helper() - - expected := make(map[string]bool, len(roles)) - for _, role := range roles { - expected[fmt.Sprintf("%s.json", role)] = true - } - - entries, err := os.ReadDir(metadataDir) - if err != nil { - t.Fatalf("AssertFilesExact: ReadDir(%q): %v", metadataDir, err) - } - - actual := make(map[string]bool, len(entries)) - for _, e := range entries { - actual[e.Name()] = true - } - - for name := range expected { - if !actual[name] { - t.Errorf("AssertFilesExact: expected file %q not found in %s", name, metadataDir) - } - } - for name := range actual { - if !expected[name] { - t.Errorf("AssertFilesExact: unexpected file %q found in %s", name, metadataDir) - } - } -} - -// TrustedMetadataTestCase defines a single table-driven test for TrustedMetadata -// operations. The Setup function returns the raw bytes to operate on; Action -// receives those bytes and returns an error (or nil on success). -type TrustedMetadataTestCase struct { - Name string - Desc string - Setup func(t *testing.T) []byte - Action func(t *testing.T, data []byte) error - WantErr bool - WantErrType error - WantErrMsg string -} - -// RunTrustedMetadataTests executes a slice of TrustedMetadataTestCase entries -// as subtests. -func RunTrustedMetadataTests(t *testing.T, tests []TrustedMetadataTestCase) { - t.Helper() - for _, tc := range tests { - t.Run(tc.Name, func(t *testing.T) { - if tc.Desc != "" { - t.Logf("desc: %s", tc.Desc) - } - - var data []byte - if tc.Setup != nil { - data = tc.Setup(t) - } - - err := tc.Action(t, data) - - if tc.WantErr { - if err == nil { - t.Errorf("expected error, got nil") - return - } - if tc.WantErrType != nil && !errors.Is(err, tc.WantErrType) { - t.Errorf("expected error type %T, got %T: %v", - tc.WantErrType, err, err) - } - if tc.WantErrMsg != "" && !strings.Contains(err.Error(), tc.WantErrMsg) { - t.Errorf("expected error containing %q, got %q", - tc.WantErrMsg, err.Error()) - } - } else if err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} diff --git a/metadata/config/config_test.go b/metadata/config/config_test.go index 2b816c49..c6df34d0 100644 --- a/metadata/config/config_test.go +++ b/metadata/config/config_test.go @@ -18,10 +18,13 @@ package config import ( + "net/http" "os" "path/filepath" "testing" + "time" + "github.com/cenkalti/backoff/v5" "github.com/stretchr/testify/assert" "github.com/theupdateframework/go-tuf/v2/internal/testutils/helpers" "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" @@ -318,3 +321,180 @@ func TestUpdaterConfigCustomFetcher(t *testing.T) { assert.Same(t, customFetcher, cfg.Fetcher) } + +// nonDefaultFetcher implements fetcher.Fetcher but is intentionally not a +// *fetcher.DefaultFetcher, so it triggers the type-assertion failure path +// in each SetDefaultFetcher* method below. +type nonDefaultFetcher struct{} + +func (nonDefaultFetcher) DownloadFile(string, int64, time.Duration) ([]byte, error) { + return nil, nil +} + +// newCfgWithFetcher builds a fresh UpdaterConfig and swaps in the given +// fetcher. The "wrong type" cases below use this to force the type +// assertion in the SetDefaultFetcher* methods to fail. +func newCfgWithFetcher(t *testing.T, f fetcher.Fetcher) *UpdaterConfig { + t.Helper() + cfg, err := New("https://example.com", helpers.CreateTestRootJSON(t)) + assert.NoError(t, err) + cfg.Fetcher = f + return cfg +} + +// TestSetDefaultFetcherHTTPClientTable covers cfg.SetDefaultFetcherHTTPClient. +// The method only does anything if cfg.Fetcher is the default fetcher. +func TestSetDefaultFetcherHTTPClientTable(t *testing.T) { + tests := []struct { + name string + fetcher fetcher.Fetcher + expectError bool + errorMsg string + }{ + { + name: "default fetcher accepts a custom http.Client", + fetcher: fetcher.NewDefaultFetcher(), + }, + { + name: "non-default fetcher is rejected", + fetcher: nonDefaultFetcher{}, + expectError: true, + errorMsg: "fetcher is not type fetcher.DefaultFetcher", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newCfgWithFetcher(t, tt.fetcher) + err := cfg.SetDefaultFetcherHTTPClient(&http.Client{Timeout: 5 * time.Second}) + if tt.expectError { + assert.ErrorContains(t, err, tt.errorMsg) + return + } + assert.NoError(t, err) + // On success the config's fetcher must still be the same + // DefaultFetcher instance (the method reassigns it inline). + assert.Same(t, tt.fetcher, cfg.Fetcher) + }) + } +} + +// TestSetDefaultFetcherTransportTable covers cfg.SetDefaultFetcherTransport. +func TestSetDefaultFetcherTransportTable(t *testing.T) { + tests := []struct { + name string + fetcher fetcher.Fetcher + expectError bool + errorMsg string + }{ + { + name: "default fetcher accepts a custom RoundTripper", + fetcher: fetcher.NewDefaultFetcher(), + }, + { + name: "non-default fetcher is rejected", + fetcher: nonDefaultFetcher{}, + expectError: true, + errorMsg: "fetcher is not type fetcher.DefaultFetcher", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newCfgWithFetcher(t, tt.fetcher) + err := cfg.SetDefaultFetcherTransport(http.DefaultTransport) + if tt.expectError { + assert.ErrorContains(t, err, tt.errorMsg) + return + } + assert.NoError(t, err) + assert.Same(t, tt.fetcher, cfg.Fetcher) + }) + } +} + +// TestSetDefaultFetcherRetryTable covers cfg.SetDefaultFetcherRetry, which +// configures constant-interval retries via the underlying fetcher. +func TestSetDefaultFetcherRetryTable(t *testing.T) { + tests := []struct { + name string + fetcher fetcher.Fetcher + interval time.Duration + count uint + expectError bool + errorMsg string + }{ + { + name: "default fetcher accepts retry settings", + fetcher: fetcher.NewDefaultFetcher(), + interval: 100 * time.Millisecond, + count: 3, + }, + { + name: "zero retry count is accepted", + fetcher: fetcher.NewDefaultFetcher(), + interval: 0, + count: 0, + }, + { + name: "non-default fetcher is rejected", + fetcher: nonDefaultFetcher{}, + interval: 50 * time.Millisecond, + count: 1, + expectError: true, + errorMsg: "fetcher is not type fetcher.DefaultFetcher", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newCfgWithFetcher(t, tt.fetcher) + err := cfg.SetDefaultFetcherRetry(tt.interval, tt.count) + if tt.expectError { + assert.ErrorContains(t, err, tt.errorMsg) + return + } + assert.NoError(t, err) + assert.Same(t, tt.fetcher, cfg.Fetcher) + }) + } +} + +// TestSetRetryOptionsTable covers cfg.SetRetryOptions, which forwards +// variadic backoff.RetryOption values to the underlying fetcher. +func TestSetRetryOptionsTable(t *testing.T) { + tests := []struct { + name string + fetcher fetcher.Fetcher + opts []backoff.RetryOption + expectError bool + errorMsg string + }{ + { + name: "default fetcher accepts no options", + fetcher: fetcher.NewDefaultFetcher(), + opts: nil, + }, + { + name: "default fetcher accepts max-tries option", + fetcher: fetcher.NewDefaultFetcher(), + opts: []backoff.RetryOption{backoff.WithMaxTries(5)}, + }, + { + name: "non-default fetcher is rejected", + fetcher: nonDefaultFetcher{}, + opts: []backoff.RetryOption{backoff.WithMaxTries(1)}, + expectError: true, + errorMsg: "fetcher is not type fetcher.DefaultFetcher", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newCfgWithFetcher(t, tt.fetcher) + err := cfg.SetRetryOptions(tt.opts...) + if tt.expectError { + assert.ErrorContains(t, err, tt.errorMsg) + return + } + assert.NoError(t, err) + assert.Same(t, tt.fetcher, cfg.Fetcher) + }) + } +} diff --git a/metadata/fetcher/fetcher_test.go b/metadata/fetcher/fetcher_test.go index aab6e91f..73387186 100644 --- a/metadata/fetcher/fetcher_test.go +++ b/metadata/fetcher/fetcher_test.go @@ -22,8 +22,11 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" + "strings" "testing" + "time" "github.com/cenkalti/backoff/v5" "github.com/stretchr/testify/assert" @@ -32,11 +35,32 @@ import ( var sampleRootData = []byte{123, 10, 32, 34, 115, 105, 103, 110, 97, 116, 117, 114, 101, 115, 34, 58, 32, 91, 10, 32, 32, 123, 10, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 52, 99, 53, 54, 100, 101, 53, 98, 54, 50, 102, 100, 48, 54, 52, 102, 99, 57, 52, 52, 53, 51, 98, 54, 56, 48, 100, 102, 100, 51, 102, 97, 102, 54, 97, 48, 49, 98, 97, 97, 97, 98, 51, 98, 101, 97, 99, 50, 57, 54, 57, 50, 48, 102, 48, 99, 99, 102, 97, 50, 50, 55, 55, 53, 34, 44, 10, 32, 32, 32, 34, 115, 105, 103, 34, 58, 32, 34, 57, 54, 57, 98, 100, 101, 99, 51, 54, 100, 54, 102, 51, 100, 99, 53, 57, 99, 49, 55, 50, 48, 50, 97, 56, 53, 50, 56, 98, 98, 51, 53, 54, 97, 54, 101, 97, 53, 52, 100, 55, 99, 99, 57, 54, 98, 98, 51, 55, 49, 101, 101, 101, 52, 56, 101, 50, 52, 48, 49, 57, 50, 98, 99, 97, 99, 100, 53, 48, 53, 49, 51, 56, 56, 50, 52, 53, 49, 52, 52, 97, 97, 99, 97, 49, 48, 51, 57, 100, 51, 101, 98, 55, 48, 54, 50, 101, 48, 56, 55, 54, 55, 57, 53, 101, 56, 49, 101, 49, 100, 53, 54, 54, 102, 56, 100, 101, 100, 50, 99, 50, 56, 52, 97, 101, 101, 48, 102, 34, 10, 32, 32, 125, 44, 10, 32, 32, 123, 10, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 52, 53, 97, 57, 53, 55, 55, 99, 97, 52, 56, 51, 102, 51, 53, 56, 98, 100, 97, 52, 97, 50, 49, 97, 102, 57, 51, 98, 55, 54, 54, 48, 98, 56, 50, 98, 100, 57, 99, 48, 101, 49, 57, 51, 48, 97, 54, 98, 55, 100, 53, 50, 49, 98, 52, 50, 56, 57, 55, 97, 48, 102, 97, 51, 34, 44, 10, 32, 32, 32, 34, 115, 105, 103, 34, 58, 32, 34, 101, 100, 102, 97, 102, 51, 99, 53, 51, 56, 97, 48, 50, 51, 101, 55, 99, 102, 53, 98, 50, 54, 51, 97, 101, 52, 101, 54, 51, 99, 51, 51, 99, 57, 52, 97, 50, 98, 102, 99, 57, 102, 101, 56, 48, 56, 53, 57, 99, 52, 57, 51, 52, 100, 52, 97, 54, 54, 98, 48, 49, 53, 98, 54, 53, 98, 57, 48, 49, 101, 99, 53, 100, 53, 50, 57, 48, 101, 97, 53, 50, 52, 51, 51, 57, 101, 54, 97, 52, 48, 98, 53, 98, 56, 100, 98, 56, 97, 57, 53, 54, 49, 102, 51, 99, 49, 48, 51, 101, 50, 97, 101, 56, 55, 98, 57, 101, 101, 48, 51, 50, 97, 57, 101, 51, 48, 48, 49, 34, 10, 32, 32, 125, 10, 32, 93, 44, 10, 32, 34, 115, 105, 103, 110, 101, 100, 34, 58, 32, 123, 10, 32, 32, 34, 95, 116, 121, 112, 101, 34, 58, 32, 34, 114, 111, 111, 116, 34, 44, 10, 32, 32, 34, 99, 111, 110, 115, 105, 115, 116, 101, 110, 116, 95, 115, 110, 97, 112, 115, 104, 111, 116, 34, 58, 32, 116, 114, 117, 101, 44, 10, 32, 32, 34, 101, 120, 112, 105, 114, 101, 115, 34, 58, 32, 34, 50, 48, 50, 49, 45, 48, 55, 45, 49, 56, 84, 49, 51, 58, 51, 55, 58, 51, 56, 90, 34, 44, 10, 32, 32, 34, 107, 101, 121, 115, 34, 58, 32, 123, 10, 32, 32, 32, 34, 51, 56, 54, 48, 48, 56, 50, 48, 102, 49, 49, 97, 53, 102, 55, 100, 55, 102, 102, 52, 50, 101, 54, 100, 102, 99, 57, 98, 48, 51, 102, 100, 54, 48, 50, 55, 50, 97, 51, 98, 101, 54, 102, 56, 57, 53, 100, 97, 50, 100, 56, 56, 50, 99, 101, 97, 56, 98, 98, 49, 101, 50, 48, 102, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 51, 56, 54, 48, 48, 56, 50, 48, 102, 49, 49, 97, 53, 102, 55, 100, 55, 102, 102, 52, 50, 101, 54, 100, 102, 99, 57, 98, 48, 51, 102, 100, 54, 48, 50, 55, 50, 97, 51, 98, 101, 54, 102, 56, 57, 53, 100, 97, 50, 100, 56, 56, 50, 99, 101, 97, 56, 98, 98, 49, 101, 50, 48, 102, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 116, 121, 112, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 118, 97, 108, 34, 58, 32, 123, 10, 32, 32, 32, 32, 32, 34, 112, 117, 98, 108, 105, 99, 34, 58, 32, 34, 53, 48, 102, 52, 56, 54, 53, 57, 54, 54, 53, 98, 51, 101, 101, 98, 50, 50, 100, 52, 57, 51, 55, 52, 101, 49, 56, 51, 49, 57, 55, 101, 101, 102, 56, 101, 52, 50, 56, 55, 54, 97, 53, 99, 98, 57, 48, 57, 99, 57, 49, 97, 98, 55, 55, 101, 52, 50, 98, 49, 101, 99, 99, 54, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 34, 115, 99, 104, 101, 109, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 52, 53, 97, 57, 53, 55, 55, 99, 97, 52, 56, 51, 102, 51, 53, 56, 98, 100, 97, 52, 97, 50, 49, 97, 102, 57, 51, 98, 55, 54, 54, 48, 98, 56, 50, 98, 100, 57, 99, 48, 101, 49, 57, 51, 48, 97, 54, 98, 55, 100, 53, 50, 49, 98, 52, 50, 56, 57, 55, 97, 48, 102, 97, 51, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 52, 53, 97, 57, 53, 55, 55, 99, 97, 52, 56, 51, 102, 51, 53, 56, 98, 100, 97, 52, 97, 50, 49, 97, 102, 57, 51, 98, 55, 54, 54, 48, 98, 56, 50, 98, 100, 57, 99, 48, 101, 49, 57, 51, 48, 97, 54, 98, 55, 100, 53, 50, 49, 98, 52, 50, 56, 57, 55, 97, 48, 102, 97, 51, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 116, 121, 112, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 118, 97, 108, 34, 58, 32, 123, 10, 32, 32, 32, 32, 32, 34, 112, 117, 98, 108, 105, 99, 34, 58, 32, 34, 49, 56, 101, 98, 50, 52, 56, 51, 49, 57, 54, 98, 55, 97, 97, 50, 53, 102, 97, 102, 98, 56, 49, 50, 55, 54, 99, 55, 48, 52, 102, 55, 57, 48, 51, 99, 99, 57, 98, 49, 101, 51, 52, 99, 97, 100, 99, 52, 101, 97, 102, 54, 55, 55, 98, 55, 97, 54, 55, 52, 100, 54, 102, 53, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 34, 115, 99, 104, 101, 109, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 52, 99, 53, 54, 100, 101, 53, 98, 54, 50, 102, 100, 48, 54, 52, 102, 99, 57, 52, 52, 53, 51, 98, 54, 56, 48, 100, 102, 100, 51, 102, 97, 102, 54, 97, 48, 49, 98, 97, 97, 97, 98, 51, 98, 101, 97, 99, 50, 57, 54, 57, 50, 48, 102, 48, 99, 99, 102, 97, 50, 50, 55, 55, 53, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 52, 99, 53, 54, 100, 101, 53, 98, 54, 50, 102, 100, 48, 54, 52, 102, 99, 57, 52, 52, 53, 51, 98, 54, 56, 48, 100, 102, 100, 51, 102, 97, 102, 54, 97, 48, 49, 98, 97, 97, 97, 98, 51, 98, 101, 97, 99, 50, 57, 54, 57, 50, 48, 102, 48, 99, 99, 102, 97, 50, 50, 55, 55, 53, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 116, 121, 112, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 118, 97, 108, 34, 58, 32, 123, 10, 32, 32, 32, 32, 32, 34, 112, 117, 98, 108, 105, 99, 34, 58, 32, 34, 57, 50, 49, 101, 99, 99, 56, 54, 101, 101, 57, 49, 102, 100, 100, 51, 97, 53, 53, 49, 52, 48, 50, 51, 100, 102, 49, 57, 99, 100, 56, 53, 57, 49, 53, 57, 52, 54, 55, 55, 54, 52, 102, 54, 48, 102, 99, 52, 49, 101, 49, 101, 101, 97, 99, 56, 53, 48, 51, 53, 49, 49, 54, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 34, 115, 99, 104, 101, 109, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 56, 102, 51, 99, 50, 55, 57, 52, 102, 50, 52, 52, 50, 54, 48, 49, 52, 102, 99, 50, 54, 97, 100, 99, 98, 98, 56, 101, 102, 100, 57, 52, 52, 49, 49, 102, 99, 101, 56, 56, 49, 102, 97, 54, 48, 102, 99, 56, 55, 50, 53, 97, 56, 57, 57, 49, 49, 53, 55, 53, 48, 101, 102, 97, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 56, 102, 51, 99, 50, 55, 57, 52, 102, 50, 52, 52, 50, 54, 48, 49, 52, 102, 99, 50, 54, 97, 100, 99, 98, 98, 56, 101, 102, 100, 57, 52, 52, 49, 49, 102, 99, 101, 56, 56, 49, 102, 97, 54, 48, 102, 99, 56, 55, 50, 53, 97, 56, 57, 57, 49, 49, 53, 55, 53, 48, 101, 102, 97, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 116, 121, 112, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 118, 97, 108, 34, 58, 32, 123, 10, 32, 32, 32, 32, 32, 34, 112, 117, 98, 108, 105, 99, 34, 58, 32, 34, 56, 57, 53, 55, 54, 57, 49, 55, 100, 49, 54, 48, 50, 56, 52, 51, 56, 52, 97, 52, 55, 55, 53, 57, 101, 101, 99, 49, 102, 99, 48, 102, 53, 98, 55, 52, 54, 99, 97, 51, 100, 102, 97, 100, 56, 49, 51, 101, 101, 51, 48, 56, 55, 53, 99, 51, 50, 98, 97, 99, 51, 54, 57, 99, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 34, 115, 99, 104, 101, 109, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 57, 100, 55, 56, 53, 52, 51, 98, 53, 48, 56, 102, 57, 57, 97, 57, 53, 97, 51, 99, 51, 49, 102, 97, 100, 51, 99, 102, 102, 101, 102, 48, 54, 52, 52, 49, 51, 52, 102, 49, 97, 48, 50, 56, 98, 51, 48, 53, 48, 49, 97, 99, 99, 49, 50, 48, 53, 56, 99, 55, 99, 51, 101, 56, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 34, 58, 32, 34, 57, 100, 55, 56, 53, 52, 51, 98, 53, 48, 56, 102, 57, 57, 97, 57, 53, 97, 51, 99, 51, 49, 102, 97, 100, 51, 99, 102, 102, 101, 102, 48, 54, 52, 52, 49, 51, 52, 102, 49, 97, 48, 50, 56, 98, 51, 48, 53, 48, 49, 97, 99, 99, 49, 50, 48, 53, 56, 99, 55, 99, 51, 101, 56, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 116, 121, 112, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 44, 10, 32, 32, 32, 32, 34, 107, 101, 121, 118, 97, 108, 34, 58, 32, 123, 10, 32, 32, 32, 32, 32, 34, 112, 117, 98, 108, 105, 99, 34, 58, 32, 34, 48, 52, 101, 102, 51, 51, 53, 54, 102, 98, 53, 99, 100, 48, 48, 57, 55, 53, 100, 102, 99, 101, 57, 102, 56, 102, 52, 50, 100, 53, 98, 49, 50, 98, 55, 98, 56, 51, 102, 56, 98, 97, 49, 53, 99, 50, 101, 57, 56, 102, 100, 48, 52, 49, 53, 49, 52, 99, 55, 52, 98, 101, 98, 50, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 34, 115, 99, 104, 101, 109, 101, 34, 58, 32, 34, 101, 100, 50, 53, 53, 49, 57, 34, 10, 32, 32, 32, 125, 10, 32, 32, 125, 44, 10, 32, 32, 34, 114, 111, 108, 101, 115, 34, 58, 32, 123, 10, 32, 32, 32, 34, 114, 111, 111, 116, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 34, 52, 53, 97, 57, 53, 55, 55, 99, 97, 52, 56, 51, 102, 51, 53, 56, 98, 100, 97, 52, 97, 50, 49, 97, 102, 57, 51, 98, 55, 54, 54, 48, 98, 56, 50, 98, 100, 57, 99, 48, 101, 49, 57, 51, 48, 97, 54, 98, 55, 100, 53, 50, 49, 98, 52, 50, 56, 57, 55, 97, 48, 102, 97, 51, 34, 44, 10, 32, 32, 32, 32, 32, 34, 52, 99, 53, 54, 100, 101, 53, 98, 54, 50, 102, 100, 48, 54, 52, 102, 99, 57, 52, 52, 53, 51, 98, 54, 56, 48, 100, 102, 100, 51, 102, 97, 102, 54, 97, 48, 49, 98, 97, 97, 97, 98, 51, 98, 101, 97, 99, 50, 57, 54, 57, 50, 48, 102, 48, 99, 99, 102, 97, 50, 50, 55, 55, 53, 34, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116, 104, 114, 101, 115, 104, 111, 108, 100, 34, 58, 32, 50, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 115, 110, 97, 112, 115, 104, 111, 116, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 34, 57, 100, 55, 56, 53, 52, 51, 98, 53, 48, 56, 102, 57, 57, 97, 57, 53, 97, 51, 99, 51, 49, 102, 97, 100, 51, 99, 102, 102, 101, 102, 48, 54, 52, 52, 49, 51, 52, 102, 49, 97, 48, 50, 56, 98, 51, 48, 53, 48, 49, 97, 99, 99, 49, 50, 48, 53, 56, 99, 55, 99, 51, 101, 56, 34, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116, 104, 114, 101, 115, 104, 111, 108, 100, 34, 58, 32, 49, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 116, 97, 114, 103, 101, 116, 115, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 34, 56, 102, 51, 99, 50, 55, 57, 52, 102, 50, 52, 52, 50, 54, 48, 49, 52, 102, 99, 50, 54, 97, 100, 99, 98, 98, 56, 101, 102, 100, 57, 52, 52, 49, 49, 102, 99, 101, 56, 56, 49, 102, 97, 54, 48, 102, 99, 56, 55, 50, 53, 97, 56, 57, 57, 49, 49, 53, 55, 53, 48, 101, 102, 97, 34, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116, 104, 114, 101, 115, 104, 111, 108, 100, 34, 58, 32, 49, 10, 32, 32, 32, 125, 44, 10, 32, 32, 32, 34, 116, 105, 109, 101, 115, 116, 97, 109, 112, 34, 58, 32, 123, 10, 32, 32, 32, 32, 34, 107, 101, 121, 105, 100, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 34, 51, 56, 54, 48, 48, 56, 50, 48, 102, 49, 49, 97, 53, 102, 55, 100, 55, 102, 102, 52, 50, 101, 54, 100, 102, 99, 57, 98, 48, 51, 102, 100, 54, 48, 50, 55, 50, 97, 51, 98, 101, 54, 102, 56, 57, 53, 100, 97, 50, 100, 56, 56, 50, 99, 101, 97, 56, 98, 98, 49, 101, 50, 48, 102, 34, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116, 104, 114, 101, 115, 104, 111, 108, 100, 34, 58, 32, 49, 10, 32, 32, 32, 125, 10, 32, 32, 125, 44, 10, 32, 32, 34, 115, 112, 101, 99, 95, 118, 101, 114, 115, 105, 111, 110, 34, 58, 32, 34, 49, 46, 48, 46, 49, 57, 34, 44, 10, 32, 32, 34, 118, 101, 114, 115, 105, 111, 110, 34, 58, 32, 49, 44, 10, 32, 32, 34, 120, 45, 116, 117, 102, 114, 101, 112, 111, 45, 101, 120, 112, 105, 114, 121, 45, 112, 101, 114, 105, 111, 100, 34, 58, 32, 56, 54, 52, 48, 48, 10, 32, 125, 10, 125} +// newRootServer returns an httptest.Server that serves sampleRootData on +// "/1.root.json" with a correct Content-Length, and 404s on every other +// path. The returned server is registered with t.Cleanup so callers don't +// have to remember to close it. +func newRootServer(t *testing.T) *httptest.Server { + t.Helper() + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/1.root.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(sampleRootData))) + _, _ = w.Write(sampleRootData) + })) + t.Cleanup(s.Close) + return s +} + +// TestDownLoadFile drives DownloadFile against a local httptest.Server +// (no external network), covering the success path, an unreachable host, +// a malformed URL, a 404 response, and length-cap enforcement. func TestDownLoadFile(t *testing.T) { - for _, tt := range []struct { + tests := []struct { name string desc string - url string + makeURL func(server *httptest.Server) string maxLength int64 data []byte wantErr error @@ -44,60 +68,76 @@ func TestDownLoadFile(t *testing.T) { { name: "success", desc: "No errors expected", - url: "https://jku.github.io/tuf-demo/metadata/1.root.json", + makeURL: func(s *httptest.Server) string { return s.URL + "/1.root.json" }, maxLength: 512000, data: sampleRootData, wantErr: nil, }, { name: "invalid url", - desc: "URL does not exist", - url: "https://somebadtufrepourl.com/metadata/", + desc: "URL does not resolve", + makeURL: func(*httptest.Server) string { return "http://127.0.0.1:1/" }, data: nil, wantErr: &url.Error{}, }, { name: "invalid url format", desc: "URL is malformed", - url: string([]byte{0x7f}), + makeURL: func(*httptest.Server) string { return string([]byte{0x7f}) }, data: nil, wantErr: &url.Error{}, }, { name: "invalid path", - desc: "Path does not exist", - url: "https://jku.github.io/tuf-demo/metadata/badPath.json", + desc: "Server returns 404", + makeURL: func(s *httptest.Server) string { return s.URL + "/badPath.json" }, data: nil, wantErr: &metadata.ErrDownloadHTTP{}, }, { name: "data too long", - desc: "Returned data is longer than maxLength", - url: "https://jku.github.io/tuf-demo/metadata/1.root.json", + desc: "Returned data exceeds maxLength", + makeURL: func(s *httptest.Server) string { return s.URL + "/1.root.json" }, maxLength: 1, data: nil, wantErr: &metadata.ErrDownloadLengthMismatch{}, }, - } { + } + + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // this will only be printed if run in verbose mode or if test fails t.Logf("Desc: %s", tt.desc) - // run the function under test + server := newRootServer(t) fetcher := NewDefaultFetcher() fetcher.SetHTTPUserAgent("Metadata_Unit_Test/1.0") - data, err := fetcher.DownloadFile(tt.url, tt.maxLength, 0) - // special case if we expect no error + data, err := fetcher.DownloadFile(tt.makeURL(server), tt.maxLength, 0) if tt.wantErr == nil { assert.NoErrorf(t, err, "expected no error but got %v", err) return } - // compare the error and data with our expected error and data assert.Equal(t, tt.data, data, "fetched data did not match") assert.IsTypef(t, tt.wantErr, err, "expected %v but got %v", tt.wantErr, err) }) } } +// TestDownLoadFile_BodyExceedsLimit covers the post-read LimitReader check +// in DownloadFile: a server that omits Content-Length but returns a body +// larger than maxLength must trip ErrDownloadLengthMismatch. +func TestDownLoadFile_BodyExceedsLimit(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No Content-Length so the in-flight check is skipped; rely on + // the LimitReader path that fires after io.ReadAll returns. + w.Header().Del("Content-Length") + _, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 200))) + })) + t.Cleanup(s.Close) + + f := NewDefaultFetcher() + _, err := f.DownloadFile(s.URL, 10, 0) + assert.ErrorAs(t, err, new(*metadata.ErrDownloadLengthMismatch)) +} + // MockHTTPClient is a mock implementation of httpClient // for simulating HTTP requests in tests type MockHTTPClient struct { @@ -171,8 +211,154 @@ func TestDownloadFile_Retry(t *testing.T) { } } +// TestDownloadFile_NoHTTPClientSet covers the "if d.client == nil, fall +// back to http.DefaultClient" branch in DownloadFile. func TestDownloadFile_NoHTTPClientSet(t *testing.T) { + server := newRootServer(t) fetcher := DefaultFetcher{} - _, err := fetcher.DownloadFile("https://jku.github.io/tuf-demo/metadata/1.root.json", 512000, 0) + _, err := fetcher.DownloadFile(server.URL+"/1.root.json", 512000, 0) assert.NoError(t, err) } + +// TestNewFetcherWithHTTPClient verifies that the constructor returns a +// fetcher whose internal client is the one we passed in. +func TestNewFetcherWithHTTPClient(t *testing.T) { + base := &DefaultFetcher{} + custom := &MockHTTPClient{SucceedOnTryN: 1} + got := base.NewFetcherWithHTTPClient(custom) + assert.NotNil(t, got) + assert.Same(t, custom, got.client) +} + +// recordingRoundTripper is a distinct RoundTripper we can identity-compare +// against to prove NewFetcherWithRoundTripper actually installed the +// caller's transport (http.DefaultTransport is already the effective +// default for http.DefaultClient, so using it would let the assertion +// pass even if the constructor stopped installing the argument). +type recordingRoundTripper struct{} + +func (*recordingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("recordingRoundTripper: unused") +} + +// TestNewFetcherWithRoundTripper verifies that the returned fetcher has the +// supplied RoundTripper installed on its http.Client. NewFetcherWithRoundTripper +// mutates http.DefaultClient as a side effect -- restore the original +// Transport after the test so we don't leak global state into later tests. +func TestNewFetcherWithRoundTripper(t *testing.T) { + originalTransport := http.DefaultClient.Transport + t.Cleanup(func() { http.DefaultClient.Transport = originalTransport }) + + base := &DefaultFetcher{} + rt := &recordingRoundTripper{} + got := base.NewFetcherWithRoundTripper(rt) + assert.NotNil(t, got) + hc, ok := got.client.(*http.Client) + if assert.True(t, ok, "client must be *http.Client") { + // Identity-compare against rt: a regression that drops the + // argument would make this fail rather than silently passing. + assert.Same(t, rt, hc.Transport) + } +} + +// TestSetHTTPClient verifies the setter swaps the underlying client. +func TestSetHTTPClient(t *testing.T) { + f := NewDefaultFetcher() + custom := &MockHTTPClient{SucceedOnTryN: 1} + f.SetHTTPClient(custom) + assert.Same(t, custom, f.client) +} + +// TestSetTransportTable covers both branches of SetTransport: the happy +// path on a *http.Client, and the failure path when the underlying client +// is something else (e.g. a test mock). +func TestSetTransportTable(t *testing.T) { + tests := []struct { + name string + setup func() *DefaultFetcher + expectError bool + errorMsg string + }{ + { + name: "fetcher backed by *http.Client accepts a new transport", + setup: func() *DefaultFetcher { + return NewDefaultFetcher() // internal client is *http.Client + }, + }, + { + name: "fetcher backed by a mock client rejects SetTransport", + setup: func() *DefaultFetcher { + f := NewDefaultFetcher() + f.SetHTTPClient(&MockHTTPClient{SucceedOnTryN: 1}) + return f + }, + expectError: true, + errorMsg: "fetcher is not type fetcher.DefaultFetcher", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := tt.setup() + rt := http.DefaultTransport + err := f.SetTransport(rt) + if tt.expectError { + assert.ErrorContains(t, err, tt.errorMsg) + return + } + assert.NoError(t, err) + hc, ok := f.client.(*http.Client) + if assert.True(t, ok) { + assert.Same(t, rt, hc.Transport) + } + }) + } +} + +// TestSetRetry verifies that SetRetry installs constant-interval retry +// options on the fetcher. +func TestSetRetry(t *testing.T) { + tests := []struct { + name string + interval time.Duration + count uint + }{ + {name: "non-zero interval and count", interval: 100 * time.Millisecond, count: 3}, + {name: "zero interval and count", interval: 0, count: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := NewDefaultFetcher() + f.SetRetry(tt.interval, tt.count) + // SetRetry replaces retryOptions with [constantBackOff, maxTries]. + assert.Len(t, f.retryOptions, 2) + }) + } +} + +// TestSetRetryOptions verifies that SetRetryOptions replaces the entire +// retryOptions slice with whatever the caller passes. +func TestSetRetryOptions(t *testing.T) { + tests := []struct { + name string + opts []backoff.RetryOption + expected int + }{ + {name: "nil clears retry options", opts: nil, expected: 0}, + {name: "one option installs one", opts: []backoff.RetryOption{backoff.WithMaxTries(5)}, expected: 1}, + { + name: "multiple options install all of them", + opts: []backoff.RetryOption{ + backoff.WithMaxTries(5), + backoff.WithBackOff(backoff.NewConstantBackOff(0)), + }, + expected: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := NewDefaultFetcher() + f.SetRetryOptions(tt.opts...) + assert.Len(t, f.retryOptions, tt.expected) + }) + } +} diff --git a/metadata/multirepo/multirepo_test.go b/metadata/multirepo/multirepo_test.go index adf6ce17..fd04acad 100644 --- a/metadata/multirepo/multirepo_test.go +++ b/metadata/multirepo/multirepo_test.go @@ -19,6 +19,8 @@ package multirepo import ( "errors" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -248,3 +250,128 @@ func TestNewRejectsInvalidRepoNames(t *testing.T) { }) } } + +// TestEnsurePathsExistTable exercises MultiRepoConfig.EnsurePathsExist +// directly: it's the one method that has no dependence on the +// per-repository updater plumbing, so we can hit every branch with a +// plain config struct. +func TestEnsurePathsExistTable(t *testing.T) { + tests := []struct { + name string + buildCfg func(t *testing.T) *MultiRepoConfig + expectError bool + errorIs error + }{ + { + name: "creates metadata and targets directories", + buildCfg: func(t *testing.T) *MultiRepoConfig { + t.Helper() + tmp := t.TempDir() + return &MultiRepoConfig{ + LocalMetadataDir: filepath.Join(tmp, "metadata"), + LocalTargetsDir: filepath.Join(tmp, "targets"), + } + }, + }, + { + name: "no-op when local cache is disabled", + buildCfg: func(t *testing.T) *MultiRepoConfig { + t.Helper() + return &MultiRepoConfig{ + DisableLocalCache: true, + LocalMetadataDir: "", // would otherwise fail + LocalTargetsDir: "", + } + }, + }, + { + name: "already-existing directories succeed", + buildCfg: func(t *testing.T) *MultiRepoConfig { + t.Helper() + tmp := t.TempDir() + md := filepath.Join(tmp, "metadata") + td := filepath.Join(tmp, "targets") + assert.NoError(t, os.MkdirAll(md, 0700)) + assert.NoError(t, os.MkdirAll(td, 0700)) + return &MultiRepoConfig{LocalMetadataDir: md, LocalTargetsDir: td} + }, + }, + { + name: "fails when paths are empty and cache is enabled", + buildCfg: func(t *testing.T) *MultiRepoConfig { + t.Helper() + return &MultiRepoConfig{LocalMetadataDir: "", LocalTargetsDir: ""} + }, + expectError: true, + errorIs: os.ErrNotExist, + }, + { + name: "fails when a path collides with an existing file", + buildCfg: func(t *testing.T) *MultiRepoConfig { + t.Helper() + tmp := t.TempDir() + file := filepath.Join(tmp, "blocking_file") + assert.NoError(t, os.WriteFile(file, []byte("x"), 0600)) + return &MultiRepoConfig{ + LocalMetadataDir: file, + LocalTargetsDir: filepath.Join(tmp, "targets"), + } + }, + expectError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.buildCfg(t) + err := cfg.EnsurePathsExist() + if tt.expectError { + assert.Error(t, err) + if tt.errorIs != nil { + assert.ErrorIs(t, err, tt.errorIs) + } + return + } + assert.NoError(t, err) + }) + } +} + +// TestNewFailureCases covers the New() construction error paths that +// fire before any per-repository updater is created. The happy path +// requires a working network of TUF repositories and is out of scope +// for this unit-level test. +func TestNewFailureCases(t *testing.T) { + validMapJSON := []byte(`{ + "repositories": { + "test-repo": ["https://example.com/repo"] + }, + "mapping": [] + }`) + rootBytes := []byte(`{"signatures":[],"signed":{}}`) + + t.Run("invalid root bytes fail updater construction", func(t *testing.T) { + cfg, err := NewConfig(validMapJSON, map[string][]byte{"test-repo": rootBytes}) + assert.NoError(t, err) + cfg.LocalMetadataDir = t.TempDir() + cfg.LocalTargetsDir = t.TempDir() + + // The root bytes pass NewConfig (which only checks "is the key + // present") but fail when updater.New tries to parse them. + _, err = New(cfg) + assert.Error(t, err) + }) + + t.Run("empty trusted roots after NewConfig", func(t *testing.T) { + cfg, err := NewConfig(validMapJSON, map[string][]byte{"test-repo": rootBytes}) + assert.NoError(t, err) + cfg.LocalMetadataDir = t.TempDir() + cfg.LocalTargetsDir = t.TempDir() + // Sabotage the trusted-roots map after config construction so + // initTUFClients hits the "trusted root missing" branch. + cfg.TrustedRoots = map[string][]byte{} + + _, err = New(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get trusted root metadata from config") + }) +} diff --git a/metadata/updater/updater_test.go b/metadata/updater/updater_test.go index ff1f4fcb..3578c17d 100644 --- a/metadata/updater/updater_test.go +++ b/metadata/updater/updater_test.go @@ -18,7 +18,9 @@ package updater import ( + "os" "path/filepath" + "runtime" "testing" "time" @@ -29,6 +31,18 @@ import ( "github.com/theupdateframework/go-tuf/v2/metadata/config" ) +// skipIfWindows guards the target-download tables -- the simulator's URL +// routing for target files uses filepath.Separator, which is "\" on +// Windows; that combined with hardcoded "/targets/" prefix checks makes +// the routing unreachable from these tests. The underlying code under +// test still gets coverage from Linux and macOS runners. +func skipIfWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("simulator target URL routing is broken on windows; see metadata/updater/updater_test.go") + } +} + // createAndRefresh creates an updater for the test repository and runs Refresh func createAndRefresh(t *testing.T, repo *simulator.TestRepository) (*Updater, error) { t.Helper() @@ -1146,3 +1160,246 @@ func TestDelegatesConsistentSnapshotTable(t *testing.T) { }) } } + +// TestGetTargetInfoTable covers GetTargetInfo lookup behaviour: returning +// the right TargetFiles for a known path, and returning the canonical "not +// found" error for an unknown one. The known-target case also exercises +// the implicit Refresh that GetTargetInfo triggers when targets isn't +// trusted yet. +func TestGetTargetInfoTable(t *testing.T) { + skipIfWindows(t) + const targetPath = "hello.txt" + targetContent := []byte("hello, table-driven world") + + tests := []struct { + name string + targetPath string + wantErr bool + wantErrMsg string + }{ + {name: "known target is returned", targetPath: targetPath}, + { + name: "unknown target returns not-found", + targetPath: "does-not-exist", + wantErr: true, + wantErrMsg: "target does-not-exist not found", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + // Construct the updater without a preceding Refresh so the + // GetTargetInfo call exercises its implicit "if Targets + // isn't trusted yet, Refresh first" branch. + cfg, err := repo.GetUpdaterConfig() + assert.NoError(t, err) + up, err := New(cfg) + assert.NoError(t, err) + + info, err := up.GetTargetInfo(tc.targetPath) + if tc.wantErr { + assert.ErrorContains(t, err, tc.wantErrMsg) + assert.Nil(t, info) + return + } + assert.NoError(t, err) + if assert.NotNil(t, info) { + assert.Equal(t, tc.targetPath, info.Path) + assert.Equal(t, int64(len(targetContent)), info.Length) + } + }) + } +} + +// TestDownloadTargetTable covers DownloadTarget across its main branches: +// happy path with the configured base URL, happy path with an explicit +// targetBaseURL argument, and the rejection when neither is set. +func TestDownloadTargetTable(t *testing.T) { + skipIfWindows(t) + // The simulator's URL routing for targets has multiple bugs that + // compound under consistent-snapshot mode -- a flat target path + // panics lastIndex, and a nested one collides with the hash-prefix + // parser. Use a doubly-nested layout and switch consistent_snapshot + // off below. + const targetPath = "a/b/doc.txt" + targetContent := []byte("doc body") + + tests := []struct { + name string + useExplicit bool // pass cfg.RemoteTargetsURL as the explicit baseURL arg + clearCfgURL bool // wipe cfg.RemoteTargetsURL before the call + wantErr bool + wantErrMsg string + }{ + {name: "uses cfg.RemoteTargetsURL when baseURL is empty"}, + { + name: "uses explicit baseURL argument", + useExplicit: true, + }, + { + name: "errors when both cfg URL and arg are empty", + clearCfgURL: true, + wantErr: true, + wantErrMsg: "targetBaseURL must be set", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + // Disable consistent-snapshot to dodge the simulator's + // hash-prefix URL parsing path for targets. + repo.Simulator.MDRoot.Signed.ConsistentSnapshot = false + repo.BumpVersion(metadata.ROOT) + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + cfg, err := repo.GetUpdaterConfig() + assert.NoError(t, err) + // The simulator's URL routing keys off the LocalDir layout + // (/metadata/* vs /targets/*); TestRepository's default + // RemoteTargetsURL (MetadataDir + "/targets") doesn't match + // either branch, so we point it at the actual TargetsDir. + cfg.RemoteTargetsURL = repo.TargetsDir + savedURL := cfg.RemoteTargetsURL + if tc.clearCfgURL { + cfg.RemoteTargetsURL = "" + } + up, err := New(cfg) + assert.NoError(t, err) + assert.NoError(t, up.Refresh()) + + info, err := up.GetTargetInfo(targetPath) + assert.NoError(t, err) + assert.NotNil(t, info) + + // When the case wants to exercise the "explicit baseURL" + // branch, pass the saved cfg URL so the download still + // resolves through the simulator. Otherwise pass empty. + var baseURL string + if tc.useExplicit { + baseURL = savedURL + } + + targetDir := t.TempDir() + dst := filepath.Join(targetDir, "downloaded") + path, data, err := up.DownloadTarget(info, dst, baseURL) + if tc.wantErr { + assert.ErrorContains(t, err, tc.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, dst, path) + assert.Equal(t, targetContent, data) + + // The file is also persisted on disk. + onDisk, err := os.ReadFile(dst) + assert.NoError(t, err) + assert.Equal(t, targetContent, onDisk) + }) + } +} + +// TestFindCachedTargetTable covers FindCachedTarget after a known +// DownloadTarget call, after a hash-mismatching local file, and when the +// local file is missing entirely. +func TestFindCachedTargetTable(t *testing.T) { + skipIfWindows(t) + // As in TestDownloadTargetTable, use a doubly-nested path so the + // simulator's URL parser doesn't trip on shallow names. + const targetPath = "a/b/cached.txt" + targetContent := []byte("cached payload") + + t.Run("returns cached path and bytes after a download", func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + repo.Simulator.MDRoot.Signed.ConsistentSnapshot = false + repo.BumpVersion(metadata.ROOT) + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + cfg, err := repo.GetUpdaterConfig() + assert.NoError(t, err) + cfg.RemoteTargetsURL = repo.TargetsDir + up, err := New(cfg) + assert.NoError(t, err) + assert.NoError(t, up.Refresh()) + info, err := up.GetTargetInfo(targetPath) + assert.NoError(t, err) + + dst := filepath.Join(t.TempDir(), "out") + _, _, err = up.DownloadTarget(info, dst, "") + assert.NoError(t, err) + + gotPath, gotData, err := up.FindCachedTarget(info, dst) + assert.NoError(t, err) + assert.Equal(t, dst, gotPath) + assert.Equal(t, targetContent, gotData) + }) + + t.Run("returns empty when the cached file is missing", func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + up, err := createAndRefresh(t, repo) + assert.NoError(t, err) + info, err := up.GetTargetInfo(targetPath) + assert.NoError(t, err) + + // Point at a file that doesn't exist. + gotPath, gotData, err := up.FindCachedTarget(info, filepath.Join(t.TempDir(), "absent")) + assert.NoError(t, err) + assert.Empty(t, gotPath) + assert.Empty(t, gotData) + }) + + t.Run("returns empty when the cached file is corrupted", func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + up, err := createAndRefresh(t, repo) + assert.NoError(t, err) + info, err := up.GetTargetInfo(targetPath) + assert.NoError(t, err) + + // Write a file with mismatching content; the cache lookup must + // reject it on hash verification. + dst := filepath.Join(t.TempDir(), "bad") + assert.NoError(t, os.WriteFile(dst, []byte("not the right bytes"), 0644)) + + gotPath, gotData, err := up.FindCachedTarget(info, dst) + assert.NoError(t, err) + assert.Empty(t, gotPath) + assert.Empty(t, gotData) + }) + + t.Run("no-op when local cache is disabled", func(t *testing.T) { + repo := simulator.NewTestRepository(t) + defer repo.Cleanup() + repo.AddTarget(metadata.TARGETS, targetContent, targetPath) + repo.UpdateSnapshot() + + cfg, err := repo.GetUpdaterConfig() + assert.NoError(t, err) + cfg.DisableLocalCache = true + up, err := New(cfg) + assert.NoError(t, err) + assert.NoError(t, up.Refresh()) + info, err := up.GetTargetInfo(targetPath) + assert.NoError(t, err) + + gotPath, gotData, err := up.FindCachedTarget(info, "") + assert.NoError(t, err) + assert.Empty(t, gotPath) + assert.Empty(t, gotData) + }) +}