mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-05-23 22:00:54 -04:00
854 lines
28 KiB
Go
854 lines
28 KiB
Go
// Copyright (c) The OpenTofu Authors
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
// Copyright (c) 2023 HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package registry
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/hashicorp/go-retryablehttp"
|
|
version "github.com/hashicorp/go-version"
|
|
regaddr "github.com/opentofu/registry-address/v2"
|
|
"github.com/opentofu/svchost/disco"
|
|
|
|
"github.com/opentofu/opentofu/internal/addrs"
|
|
"github.com/opentofu/opentofu/internal/httpclient"
|
|
"github.com/opentofu/opentofu/internal/registry/response"
|
|
"github.com/opentofu/opentofu/internal/registry/test"
|
|
)
|
|
|
|
func TestLookupModuleVersions(t *testing.T) {
|
|
server := test.Registry()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
// test with and without a hostname
|
|
for _, src := range []string{
|
|
"example.com/test-versions/name/provider",
|
|
"test-versions/name/provider",
|
|
} {
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
resp, err := client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(resp.Modules) != 1 {
|
|
t.Fatal("expected 1 module, got", len(resp.Modules))
|
|
}
|
|
|
|
mod := resp.Modules[0]
|
|
name := "test-versions/name/provider"
|
|
if mod.Source != name {
|
|
t.Fatalf("expected module name %q, got %q", name, mod.Source)
|
|
}
|
|
|
|
if len(mod.Versions) != 4 {
|
|
t.Fatal("expected 4 versions, got", len(mod.Versions))
|
|
}
|
|
|
|
for _, v := range mod.Versions {
|
|
_, err := version.NewVersion(v.Version)
|
|
if err != nil {
|
|
t.Fatalf("invalid version %q: %s", v.Version, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidRegistry(t *testing.T) {
|
|
server := test.Registry()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
src := "non-existent.localhost.localdomain/test-versions/name/provider"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
|
|
if _, err := client.ModulePackageVersions(context.Background(), modsrc); err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
func TestRegistryAuth(t *testing.T) {
|
|
server := test.Registry()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
src := "private/name/provider"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
|
|
_, err := client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = client.ModulePackageLocation(context.Background(), modsrc, "1.0.0", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Also test without a credentials source
|
|
client.services.SetCredentialsSource(nil)
|
|
|
|
// both should fail without auth
|
|
_, err = client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
_, err = client.ModulePackageLocation(context.Background(), modsrc, "1.0.0", "")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
func TestLookupModuleLocationRelative(t *testing.T) {
|
|
server := test.Registry()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
src := "relative/foo/bar"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
|
|
got, err := client.ModulePackageLocation(context.Background(), modsrc, "0.2.0", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := PackageLocationIndirect{
|
|
SourceAddr: addrs.ModuleSourceRemote{
|
|
Package: addrs.ModulePackage(server.URL + "/relative-path"),
|
|
},
|
|
}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Error("wrong location\n" + diff)
|
|
}
|
|
}
|
|
|
|
func TestAccLookupModuleVersions(t *testing.T) {
|
|
if os.Getenv("TF_ACC") == "" {
|
|
t.Skip()
|
|
}
|
|
regDisco := disco.New(
|
|
disco.WithHTTPClient(httpclient.New(t.Context())),
|
|
)
|
|
|
|
// test with and without a hostname
|
|
for _, src := range []string{
|
|
"terraform-aws-modules/vpc/aws",
|
|
regaddr.DefaultModuleRegistryHost.String() + "/terraform-aws-modules/vpc/aws",
|
|
} {
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
|
|
s := NewClient(t.Context(), regDisco, nil)
|
|
resp, err := s.ModulePackageVersions(context.Background(), modsrc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(resp.Modules) != 1 {
|
|
t.Fatal("expected 1 module, got", len(resp.Modules))
|
|
}
|
|
|
|
mod := resp.Modules[0]
|
|
if len(mod.Versions) == 0 {
|
|
t.Fatal("expected multiple versions, got 0")
|
|
}
|
|
for _, v := range mod.Versions {
|
|
_, err := version.NewVersion(v.Version)
|
|
if err != nil {
|
|
t.Fatalf("invalid version %q: %s", v.Version, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// the error should reference the config source exactly, not the discovered path.
|
|
func TestLookupLookupModuleError(t *testing.T) {
|
|
server := test.Registry()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
// this should not be found in the registry
|
|
src := "bad/local/path"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
|
|
// Instrument CheckRetry to make sure 404s are not retried
|
|
retries := 0
|
|
oldCheck := client.client.CheckRetry
|
|
client.client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
|
if retries > 0 {
|
|
t.Fatal("retried after module not found")
|
|
}
|
|
retries++
|
|
return oldCheck(ctx, resp, err)
|
|
}
|
|
|
|
_, err := client.ModulePackageLocation(context.Background(), modsrc, "0.2.0", "")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
// Check for the exact quoted string to ensure we prepended the registry
|
|
// hostname. As a convention we always use the fully-qualified address
|
|
// syntax when reporting errors because it's helpful for debugging to be
|
|
// explicit about what registry we were trying to talk to.
|
|
//
|
|
// (Historically this code made the opposite decision and just echoed back
|
|
// the provided source address exactly as given, but we later made a
|
|
// cross-cutting decision to use canonical forms of addresses in error
|
|
// messages unless we're reporting a syntax error in particular, which
|
|
// is not the case here: this is a failed lookup of a valid address.)
|
|
if !strings.Contains(err.Error(), `"registry.opentofu.org/bad/local/path"`) {
|
|
t.Fatal("error should not include the hostname. got:", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupModuleRetryError(t *testing.T) {
|
|
server := test.RegistryRetryableErrorsServer()
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
src := "example.com/test-versions/name/provider"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
resp, err := client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err == nil {
|
|
t.Fatal("expected requests to exceed retry", err)
|
|
}
|
|
if resp != nil {
|
|
t.Fatal("unexpected response", *resp)
|
|
}
|
|
|
|
// verify maxRetryErrorHandler handler returned the error
|
|
if !strings.Contains(err.Error(), "request failed after 2 attempts") {
|
|
t.Fatal("unexpected error, got:", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupModuleNoRetryError(t *testing.T) {
|
|
server := test.RegistryRetryableErrorsServer()
|
|
defer server.Close()
|
|
|
|
client := NewClient(
|
|
t.Context(), test.Disco(server),
|
|
// Retries are disabled by the second argument to this function
|
|
httpclient.NewForRegistryRequests(t.Context(), 0, 10*time.Second),
|
|
)
|
|
|
|
src := "example.com/test-versions/name/provider"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
resp, err := client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err == nil {
|
|
t.Fatal("expected request to fail", err)
|
|
}
|
|
if resp != nil {
|
|
t.Fatal("unexpected response", *resp)
|
|
}
|
|
|
|
// verify maxRetryErrorHandler handler returned the error
|
|
if !strings.Contains(err.Error(), "request failed:") {
|
|
t.Fatal("unexpected error, got:", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupModuleNetworkError(t *testing.T) {
|
|
server := test.RegistryRetryableErrorsServer()
|
|
client := NewClient(t.Context(), test.Disco(server), nil)
|
|
|
|
// Shut down the server to simulate network failure
|
|
server.Close()
|
|
|
|
src := "example.com/test-versions/name/provider"
|
|
modsrc := testParseModulePackageAddr(t, src)
|
|
resp, err := client.ModulePackageVersions(context.Background(), modsrc)
|
|
if err == nil {
|
|
t.Fatal("expected request to fail", err)
|
|
}
|
|
if resp != nil {
|
|
t.Fatal("unexpected response", *resp)
|
|
}
|
|
|
|
// verify maxRetryErrorHandler handler returned the correct error
|
|
if !strings.Contains(err.Error(), "request failed after 2 attempts") {
|
|
t.Fatal("unexpected error, got:", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleLocation_readRegistryResponse(t *testing.T) {
|
|
makeIndirectLocation := func(packageAddr string, subDir string) PackageLocationIndirect {
|
|
return PackageLocationIndirect{
|
|
SourceAddr: addrs.ModuleSourceRemote{
|
|
Package: addrs.ModulePackage(packageAddr),
|
|
Subdir: subDir,
|
|
},
|
|
}
|
|
}
|
|
mustParseURL := func(s string) *url.URL {
|
|
ret, err := url.Parse(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
cases := map[string]struct {
|
|
src string
|
|
handlerFunc func(w http.ResponseWriter, r *http.Request)
|
|
registryFlags []uint8
|
|
want PackageLocation
|
|
wantErrorStr string
|
|
wantToReadFromHeader bool
|
|
wantStatusCode int
|
|
}{
|
|
"shall find direct module location in the registry response body, opting to use the registry's credentials": {
|
|
src: "exists-in-registry/identifier/provider",
|
|
want: PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "exists-in-registry/identifier/provider"),
|
|
packageURL: mustParseURL("https://example.com/package.zip"),
|
|
useRegistryCredentials: true,
|
|
},
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"location":"https://example.com/package.zip","use_registry_credentials":true}`))
|
|
},
|
|
},
|
|
"shall find direct module location in the registry response body, not opting to use the registry's credentials": {
|
|
src: "exists-in-registry/identifier/provider",
|
|
want: PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "exists-in-registry/identifier/provider"),
|
|
packageURL: mustParseURL("https://example.com/package.zip"),
|
|
useRegistryCredentials: false,
|
|
},
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"location":"https://example.com/package.zip","use_registry_credentials":false}`))
|
|
},
|
|
},
|
|
"shall find indirect module location in the registry response body": {
|
|
src: "exists-in-registry/identifier/provider",
|
|
want: makeIndirectLocation("file:///registry/exists", ""),
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(response.ModuleLocationRegistryResp{Location: "file:///registry/exists"})
|
|
},
|
|
},
|
|
"shall find indirect module location in the registry response header": {
|
|
src: "exists-in-registry/identifier/provider",
|
|
registryFlags: []uint8{test.WithModuleLocationInHeader},
|
|
want: makeIndirectLocation("file:///registry/exists", ""),
|
|
wantToReadFromHeader: true,
|
|
wantStatusCode: http.StatusNoContent,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Terraform-Get", "file:///registry/exists")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
},
|
|
},
|
|
"shall read indirect location from the registry response body even if the header with location address is also set": {
|
|
src: "exists-in-registry/identifier/provider",
|
|
want: makeIndirectLocation("file:///registry/exists", ""),
|
|
wantStatusCode: http.StatusOK,
|
|
wantToReadFromHeader: false,
|
|
registryFlags: []uint8{test.WithModuleLocationInBody, test.WithModuleLocationInHeader},
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Terraform-Get", "file:///registry/exists-header")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(response.ModuleLocationRegistryResp{Location: "file:///registry/exists"})
|
|
},
|
|
},
|
|
"shall fail to find the module": {
|
|
src: "not-exist/identifier/provider",
|
|
// note that the version is fixed in the mock
|
|
// see: /internal/registry/test/mock_registry.go:testMods
|
|
wantErrorStr: `module "registry.opentofu.org/not-exist/identifier/provider" version "0.2.0" not found`,
|
|
wantStatusCode: http.StatusNotFound,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
},
|
|
"shall fail because of reading response body error": {
|
|
src: "foo/bar/baz",
|
|
wantErrorStr: "error reading response body from registry",
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "1000") // Set incorrect content length
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{")) // Only write a partial response
|
|
// The connection will close after handler returns, but client will expect more data
|
|
},
|
|
},
|
|
"shall fail to deserialize JSON response": {
|
|
src: "foo/bar/baz",
|
|
wantErrorStr: `module "registry.opentofu.org/foo/bar/baz" version "0.2.0" failed to deserialize response body {: unexpected end of JSON input`,
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{"))
|
|
},
|
|
},
|
|
"shall fail because of unexpected protocol change - 422 http status": {
|
|
src: "foo/bar/baz",
|
|
wantErrorStr: `error getting download location for "registry.opentofu.org/foo/bar/baz": 422 Unprocessable Entity resp:bar`,
|
|
wantStatusCode: http.StatusUnprocessableEntity,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte("bar"))
|
|
},
|
|
},
|
|
"shall fail because location is not found in the response": {
|
|
src: "foo/bar/baz",
|
|
wantErrorStr: `registry did not return a location for this package`,
|
|
wantStatusCode: http.StatusOK,
|
|
handlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// note that the response emulates a contract change
|
|
_, _ = w.Write([]byte(`{"foo":"git::https://github.com/foo/terraform-baz-bar?ref=v0.2.0"}`))
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Parallel()
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
mockServer := httptest.NewServer(http.HandlerFunc(tc.handlerFunc))
|
|
defer mockServer.Close()
|
|
|
|
registryServer := test.Registry(tc.registryFlags...)
|
|
defer registryServer.Close()
|
|
|
|
transport := &testTransport{
|
|
mockURL: mockServer.URL,
|
|
}
|
|
httpClient := retryablehttp.NewClient()
|
|
httpClient.HTTPClient.Transport = transport
|
|
client := NewClient(t.Context(), test.Disco(registryServer), httpClient)
|
|
modsrc := testParseModulePackageAddr(t, tc.src)
|
|
|
|
got, err := client.ModulePackageLocation(context.Background(), modsrc, "0.2.0", "")
|
|
|
|
// Validate the results
|
|
if err != nil && tc.wantErrorStr == "" {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), tc.wantErrorStr) {
|
|
t.Fatalf("unexpected error content: want=%s, got=%v", tc.wantErrorStr, err)
|
|
}
|
|
if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(PackageLocationDirect{})); diff != "" {
|
|
t.Fatal("unexpected location\n" + diff)
|
|
}
|
|
|
|
// Verify status code if we have a successful response
|
|
if transport.lastResponse != nil {
|
|
gotStatusCode := transport.lastResponse.StatusCode
|
|
if tc.wantStatusCode != gotStatusCode {
|
|
t.Fatalf("unexpected response status code: want=%d, got=%d", tc.wantStatusCode, gotStatusCode)
|
|
}
|
|
|
|
// Check if we expected to read from header
|
|
if tc.wantToReadFromHeader && err == nil {
|
|
headerVal := transport.lastResponse.Header.Get("X-Terraform-Get")
|
|
if headerVal == "" {
|
|
t.Fatalf("expected to read location from header but X-Terraform-Get header was not set")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testParseModulePackageAddr(t *testing.T, src string) regaddr.ModulePackage {
|
|
sourceAddr, err := addrs.ParseModuleSourceRegistry(src)
|
|
if err != nil {
|
|
t.Fatalf("invalid module package address %q: %s", src, err)
|
|
}
|
|
// We used ParseModuleSourceRegistry and it didn't return an error, so this
|
|
// type assertion should always succeed.
|
|
registrySourceAddr := sourceAddr.(addrs.ModuleSourceRegistry)
|
|
if registrySourceAddr.Subdir != "" {
|
|
// Handling the "subdir" part of a source address is not part of
|
|
// the registry client's scope: that must be handled by the main
|
|
// module installer code after the registry client deals with
|
|
// the module-package-level questions.
|
|
t.Fatalf("invalid module package address %q: subdirectory part not allowed", src)
|
|
}
|
|
return registrySourceAddr.Package
|
|
}
|
|
|
|
// testTransport is a custom http.RoundTripper that redirects requests to the mock server
|
|
// and captures the response for inspection
|
|
type testTransport struct {
|
|
mockURL string
|
|
// Store the last response received from the mock server
|
|
lastResponse *http.Response
|
|
}
|
|
|
|
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Create a new request to the mock server with the same path, method, body, etc.
|
|
mockReq := &http.Request{
|
|
Method: req.Method,
|
|
URL: &url.URL{
|
|
Scheme: "http",
|
|
Host: strings.TrimPrefix(t.mockURL, "http://"),
|
|
Path: req.URL.Path,
|
|
},
|
|
Header: req.Header,
|
|
Body: req.Body,
|
|
Host: req.Host,
|
|
Proto: req.Proto,
|
|
ProtoMajor: req.ProtoMajor,
|
|
ProtoMinor: req.ProtoMinor,
|
|
}
|
|
|
|
// Send the request to the mock server
|
|
resp, err := http.DefaultTransport.RoundTrip(mockReq)
|
|
if err == nil {
|
|
t.lastResponse = resp
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
// TestInstallModulePackage tests the InstallModulePackage method
|
|
func TestInstallModulePackage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a minimal valid zip file for testing
|
|
createTestZip := func(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
var buf strings.Builder
|
|
w := zip.NewWriter(&buf)
|
|
for name, content := range files {
|
|
f, err := w.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file in zip: %v", err)
|
|
}
|
|
_, err = f.Write([]byte(content))
|
|
if err != nil {
|
|
t.Fatalf("failed to write file content: %v", err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("failed to close zip writer: %v", err)
|
|
}
|
|
return []byte(buf.String())
|
|
}
|
|
|
|
t.Run("successful download and extraction", func(t *testing.T) {
|
|
zipContent := createTestZip(t, map[string]string{
|
|
"main.tf": "# test module",
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(zipContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
modDir, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if modDir != targetDir {
|
|
t.Errorf("expected modDir %q, got %q", targetDir, modDir)
|
|
}
|
|
|
|
// Verify the file was extracted
|
|
content, err := os.ReadFile(targetDir + "/main.tf")
|
|
if err != nil {
|
|
t.Fatalf("failed to read extracted file: %v", err)
|
|
}
|
|
if string(content) != "# test module" {
|
|
t.Errorf("unexpected file content: %q", content)
|
|
}
|
|
})
|
|
|
|
t.Run("Content-Length mismatch error", func(t *testing.T) {
|
|
zipContent := createTestZip(t, map[string]string{
|
|
"main.tf": "# test module",
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Claim more bytes than we actually send
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipContent)+100))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(zipContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err == nil {
|
|
t.Fatal("expected error for Content-Length mismatch")
|
|
}
|
|
// The error could be either a Content-Length mismatch or an EOF error
|
|
// depending on how the HTTP client handles the truncated response
|
|
if !strings.Contains(err.Error(), "server promised") && !strings.Contains(err.Error(), "EOF") {
|
|
t.Errorf("expected Content-Length mismatch or EOF error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("network failure handling", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Close connection immediately to simulate network failure
|
|
hj, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
t.Fatal("server doesn't support hijacking")
|
|
}
|
|
conn, _, _ := hj.Hijack()
|
|
conn.Close()
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err == nil {
|
|
t.Fatal("expected error for network failure")
|
|
}
|
|
})
|
|
|
|
t.Run("subdirectory path handling", func(t *testing.T) {
|
|
zipContent := createTestZip(t, map[string]string{
|
|
"subdir/main.tf": "# test module in subdir",
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(zipContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
subdir: "subdir",
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
modDir, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
expectedModDir := filepath.Join(targetDir, "subdir")
|
|
if modDir != expectedModDir {
|
|
t.Errorf("expected modDir %q, got %q", expectedModDir, modDir)
|
|
}
|
|
|
|
// Verify the file was extracted to the subdirectory
|
|
content, err := os.ReadFile(filepath.Join(modDir, "main.tf"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read extracted file: %v", err)
|
|
}
|
|
if string(content) != "# test module in subdir" {
|
|
t.Errorf("unexpected file content: %q", content)
|
|
}
|
|
})
|
|
|
|
t.Run("HTTP error response", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte("not found"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP error response")
|
|
}
|
|
// The error should indicate a download failure due to the non-2xx HTTP status
|
|
if !strings.Contains(err.Error(), "failed to download module package") {
|
|
t.Errorf("expected download error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid archive format", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("this is not a valid archive"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(t.Context(), nil, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: testParseModulePackageAddr(t, "test/module/provider"),
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid archive format")
|
|
}
|
|
if !strings.Contains(err.Error(), "module package is not zip archive") {
|
|
t.Errorf("expected archive format error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestInstallModulePackage_CredentialAttachment tests that credentials are
|
|
// correctly attached or omitted based on the useRegistryCredentials flag
|
|
func TestInstallModulePackage_CredentialAttachment(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a minimal valid zip file for testing
|
|
createTestZip := func(t *testing.T) []byte {
|
|
t.Helper()
|
|
var buf strings.Builder
|
|
w := zip.NewWriter(&buf)
|
|
f, _ := w.Create("main.tf")
|
|
_, _ = f.Write([]byte("# test"))
|
|
_ = w.Close()
|
|
return []byte(buf.String())
|
|
}
|
|
|
|
t.Run("credentials attached when useRegistryCredentials is true", func(t *testing.T) {
|
|
zipContent := createTestZip(t)
|
|
var receivedAuthHeader string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedAuthHeader = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(zipContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create a disco with credentials using the test package's approach
|
|
d := test.Disco(httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
|
|
|
|
client := NewClient(t.Context(), d, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: regaddr.ModulePackage{
|
|
Host: "registry.opentofu.org",
|
|
Namespace: "test",
|
|
Name: "module",
|
|
TargetSystem: "provider",
|
|
},
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: true,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if receivedAuthHeader == "" {
|
|
t.Error("expected Authorization header to be set when useRegistryCredentials is true")
|
|
}
|
|
if !strings.Contains(receivedAuthHeader, "test-auth-token") {
|
|
t.Errorf("expected Authorization header to contain token, got: %q", receivedAuthHeader)
|
|
}
|
|
})
|
|
|
|
t.Run("credentials not attached when useRegistryCredentials is false", func(t *testing.T) {
|
|
zipContent := createTestZip(t)
|
|
var receivedAuthHeader string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedAuthHeader = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(zipContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create a disco with credentials using the test package's approach
|
|
d := test.Disco(httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
|
|
|
|
client := NewClient(t.Context(), d, nil)
|
|
targetDir := t.TempDir()
|
|
|
|
packageURL, _ := url.Parse(server.URL + "/package.zip")
|
|
location := PackageLocationDirect{
|
|
packageAddr: regaddr.ModulePackage{
|
|
Host: "registry.opentofu.org",
|
|
Namespace: "test",
|
|
Name: "module",
|
|
TargetSystem: "provider",
|
|
},
|
|
packageURL: packageURL,
|
|
useRegistryCredentials: false,
|
|
}
|
|
|
|
_, err := client.InstallModulePackage(context.Background(), location, targetDir)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if receivedAuthHeader != "" {
|
|
t.Errorf("expected no Authorization header when useRegistryCredentials is false, got: %q", receivedAuthHeader)
|
|
}
|
|
})
|
|
}
|