opentofu/internal/addrs/module_source_test.go
chenzj 2485299cd4
Fix: unit test of package internal/addr for windows #1281 (#1325)
Signed-off-by: Zejun Chen <tibazq@gmail.com>
2024-03-04 11:41:04 +00:00

660 lines
24 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 addrs
import (
"runtime"
"testing"
"github.com/google/go-cmp/cmp"
svchost "github.com/hashicorp/terraform-svchost"
)
func TestParseModuleSource(t *testing.T) {
absolutePath, absolutePathModulePackage := testDataAbsolutePath()
absolutePathSubdir, absolutePathSubdirModulePackage := testDataAbsolutePathSubdir()
tests := map[string]struct {
input string
want ModuleSource
wantErr string
}{
// Local paths
"local in subdirectory": {
input: "./child",
want: ModuleSourceLocal("./child"),
},
"local in subdirectory non-normalized": {
input: "./nope/../child",
want: ModuleSourceLocal("./child"),
},
"local in sibling directory": {
input: "../sibling",
want: ModuleSourceLocal("../sibling"),
},
"local in sibling directory non-normalized": {
input: "./nope/../../sibling",
want: ModuleSourceLocal("../sibling"),
},
"Windows-style local in subdirectory": {
input: `.\child`,
want: ModuleSourceLocal("./child"),
},
"Windows-style local in subdirectory non-normalized": {
input: `.\nope\..\child`,
want: ModuleSourceLocal("./child"),
},
"Windows-style local in sibling directory": {
input: `..\sibling`,
want: ModuleSourceLocal("../sibling"),
},
"Windows-style local in sibling directory non-normalized": {
input: `.\nope\..\..\sibling`,
want: ModuleSourceLocal("../sibling"),
},
"an abominable mix of different slashes": {
input: `./nope\nope/why\./please\don't`,
want: ModuleSourceLocal("./nope/nope/why/please/don't"),
},
// Registry addresses
// (NOTE: There is another test function TestParseModuleSourceRegistry
// which tests this situation more exhaustively, so this is just a
// token set of cases to see that we are indeed calling into the
// registry address parser when appropriate.)
"main registry implied": {
input: "hashicorp/subnets/cidr",
want: ModuleSourceRegistry{
Package: ModuleRegistryPackage{
Host: svchost.Hostname("registry.opentofu.org"),
Namespace: "hashicorp",
Name: "subnets",
TargetSystem: "cidr",
},
Subdir: "",
},
},
"main registry implied, subdir": {
input: "hashicorp/subnets/cidr//examples/foo",
want: ModuleSourceRegistry{
Package: ModuleRegistryPackage{
Host: svchost.Hostname("registry.opentofu.org"),
Namespace: "hashicorp",
Name: "subnets",
TargetSystem: "cidr",
},
Subdir: "examples/foo",
},
},
"main registry implied, escaping subdir": {
input: "hashicorp/subnets/cidr//../nope",
// NOTE: This error is actually being caught by the _remote package_
// address parser, because any registry parsing failure falls back
// to that but both of them have the same subdir validation. This
// case is here to make sure that stays true, so we keep reporting
// a suitable error when the user writes a registry-looking thing.
wantErr: `subdirectory path "../nope" leads outside of the module package`,
},
"custom registry": {
input: "example.com/awesomecorp/network/happycloud",
want: ModuleSourceRegistry{
Package: ModuleRegistryPackage{
Host: svchost.Hostname("example.com"),
Namespace: "awesomecorp",
Name: "network",
TargetSystem: "happycloud",
},
Subdir: "",
},
},
"custom registry, subdir": {
input: "example.com/awesomecorp/network/happycloud//examples/foo",
want: ModuleSourceRegistry{
Package: ModuleRegistryPackage{
Host: svchost.Hostname("example.com"),
Namespace: "awesomecorp",
Name: "network",
TargetSystem: "happycloud",
},
Subdir: "examples/foo",
},
},
// Remote package addresses
"github.com shorthand": {
input: "github.com/hashicorp/terraform-cidr-subnets",
want: ModuleSourceRemote{
Package: ModulePackage("git::https://github.com/hashicorp/terraform-cidr-subnets.git"),
},
},
"github.com shorthand, subdir": {
input: "github.com/hashicorp/terraform-cidr-subnets//example/foo",
want: ModuleSourceRemote{
Package: ModulePackage("git::https://github.com/hashicorp/terraform-cidr-subnets.git"),
Subdir: "example/foo",
},
},
"git protocol, URL-style": {
input: "git://example.com/code/baz.git",
want: ModuleSourceRemote{
Package: ModulePackage("git://example.com/code/baz.git"),
},
},
"git protocol, URL-style, subdir": {
input: "git://example.com/code/baz.git//bleep/bloop",
want: ModuleSourceRemote{
Package: ModulePackage("git://example.com/code/baz.git"),
Subdir: "bleep/bloop",
},
},
"git over HTTPS, URL-style": {
input: "git::https://example.com/code/baz.git",
want: ModuleSourceRemote{
Package: ModulePackage("git::https://example.com/code/baz.git"),
},
},
"git over HTTPS, URL-style, subdir": {
input: "git::https://example.com/code/baz.git//bleep/bloop",
want: ModuleSourceRemote{
Package: ModulePackage("git::https://example.com/code/baz.git"),
Subdir: "bleep/bloop",
},
},
"git over HTTPS, URL-style, subdir, query parameters": {
input: "git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah",
want: ModuleSourceRemote{
Package: ModulePackage("git::https://example.com/code/baz.git?otherthing=blah"),
Subdir: "bleep/bloop",
},
},
"git over SSH, URL-style": {
input: "git::ssh://git@example.com/code/baz.git",
want: ModuleSourceRemote{
Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
},
},
"git over SSH, URL-style, subdir": {
input: "git::ssh://git@example.com/code/baz.git//bleep/bloop",
want: ModuleSourceRemote{
Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
Subdir: "bleep/bloop",
},
},
"git over SSH, scp-style": {
input: "git::git@example.com:code/baz.git",
want: ModuleSourceRemote{
// Normalized to URL-style
Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
},
},
"git over SSH, scp-style, subdir": {
input: "git::git@example.com:code/baz.git//bleep/bloop",
want: ModuleSourceRemote{
// Normalized to URL-style
Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
Subdir: "bleep/bloop",
},
},
// NOTE: We intentionally don't test the bitbucket.org shorthands
// here, because that detector makes direct HTTP tequests to the
// Bitbucket API and thus isn't appropriate for unit testing.
"Google Cloud Storage bucket implied, path prefix": {
input: "www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE",
want: ModuleSourceRemote{
Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE"),
},
},
"Google Cloud Storage bucket, path prefix": {
input: "gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE",
want: ModuleSourceRemote{
Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE"),
},
},
"Google Cloud Storage bucket implied, archive object": {
input: "www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip",
want: ModuleSourceRemote{
Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip"),
},
},
"Google Cloud Storage bucket, archive object": {
input: "gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip",
want: ModuleSourceRemote{
Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip"),
},
},
"Amazon S3 bucket implied, archive object": {
input: "s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip",
want: ModuleSourceRemote{
Package: ModulePackage("s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"),
},
},
"Amazon S3 bucket, archive object": {
input: "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip",
want: ModuleSourceRemote{
Package: ModulePackage("s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"),
},
},
"HTTP URL": {
input: "http://example.com/module",
want: ModuleSourceRemote{
Package: ModulePackage("http://example.com/module"),
},
},
"HTTPS URL": {
input: "https://example.com/module",
want: ModuleSourceRemote{
Package: ModulePackage("https://example.com/module"),
},
},
"HTTPS URL, archive file": {
input: "https://example.com/module.zip",
want: ModuleSourceRemote{
Package: ModulePackage("https://example.com/module.zip"),
},
},
"HTTPS URL, forced archive file": {
input: "https://example.com/module?archive=tar",
want: ModuleSourceRemote{
Package: ModulePackage("https://example.com/module?archive=tar"),
},
},
"HTTPS URL, forced archive file and checksum": {
input: "https://example.com/module?archive=tar&checksum=blah",
want: ModuleSourceRemote{
// The query string only actually gets processed when we finally
// do the get, so "checksum=blah" is accepted as valid up
// at this parsing layer.
Package: ModulePackage("https://example.com/module?archive=tar&checksum=blah"),
},
},
"absolute filesystem path": {
// Although a local directory isn't really "remote", we do
// treat it as such because we still need to do all of the same
// high-level steps to work with these, even though "downloading"
// is replaced by a deep filesystem copy instead.
input: absolutePath,
want: ModuleSourceRemote{
Package: ModulePackage(absolutePathModulePackage),
},
},
"absolute filesystem path, subdir": {
// This is a funny situation where the user wants to use a
// directory elsewhere on their system as a package containing
// multiple modules, but the entry point is not at the root
// of that subtree, and so they can use the usual subdir
// syntax to move the package root higher in the real filesystem.
input: absolutePathSubdir,
want: ModuleSourceRemote{
Package: ModulePackage(absolutePathSubdirModulePackage),
Subdir: "example",
},
},
"subdir escaping out of package": {
// This is general logic for all subdir regardless of installation
// protocol, but we're using a filesystem path here just as an
// easy placeholder/
input: "/tmp/foo//example/../../invalid",
wantErr: `subdirectory path "../invalid" leads outside of the module package`,
},
"relative path without the needed prefix": {
input: "boop/bloop",
// For this case we return a generic error message from the addrs
// layer, but using a specialized error type which our module
// installer checks for and produces an extra hint for users who
// were intending to write a local path which then got
// misinterpreted as a remote source due to the missing prefix.
// However, the main message is generic here because this is really
// just a general "this string doesn't match any of our source
// address patterns" situation, not _necessarily_ about relative
// local paths.
wantErr: `OpenTofu cannot detect a supported external module source type for boop/bloop`,
},
"go-getter will accept all sorts of garbage": {
input: "dfgdfgsd:dgfhdfghdfghdfg/dfghdfghdfg",
want: ModuleSourceRemote{
// Unfortunately go-getter doesn't actually reject a totally
// invalid address like this until getting time, as long as
// it looks somewhat like a URL.
Package: ModulePackage("dfgdfgsd:dgfhdfghdfghdfg/dfghdfghdfg"),
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
addr, err := ParseModuleSource(test.input)
if test.wantErr != "" {
switch {
case err == nil:
t.Errorf("unexpected success\nwant error: %s", test.wantErr)
case err.Error() != test.wantErr:
t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if diff := cmp.Diff(addr, test.want); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
}
}
func TestModuleSourceRemoteFromRegistry(t *testing.T) {
t.Run("both have subdir", func(t *testing.T) {
remote := ModuleSourceRemote{
Package: ModulePackage("boop"),
Subdir: "foo",
}
registry := ModuleSourceRegistry{
Subdir: "bar",
}
gotAddr := remote.FromRegistry(registry)
if remote.Subdir != "foo" {
t.Errorf("FromRegistry modified the reciever; should be pure function")
}
if registry.Subdir != "bar" {
t.Errorf("FromRegistry modified the given address; should be pure function")
}
if got, want := gotAddr.Subdir, "foo/bar"; got != want {
t.Errorf("wrong resolved subdir\ngot: %s\nwant: %s", got, want)
}
})
t.Run("only remote has subdir", func(t *testing.T) {
remote := ModuleSourceRemote{
Package: ModulePackage("boop"),
Subdir: "foo",
}
registry := ModuleSourceRegistry{
Subdir: "",
}
gotAddr := remote.FromRegistry(registry)
if remote.Subdir != "foo" {
t.Errorf("FromRegistry modified the reciever; should be pure function")
}
if registry.Subdir != "" {
t.Errorf("FromRegistry modified the given address; should be pure function")
}
if got, want := gotAddr.Subdir, "foo"; got != want {
t.Errorf("wrong resolved subdir\ngot: %s\nwant: %s", got, want)
}
})
t.Run("only registry has subdir", func(t *testing.T) {
remote := ModuleSourceRemote{
Package: ModulePackage("boop"),
Subdir: "",
}
registry := ModuleSourceRegistry{
Subdir: "bar",
}
gotAddr := remote.FromRegistry(registry)
if remote.Subdir != "" {
t.Errorf("FromRegistry modified the reciever; should be pure function")
}
if registry.Subdir != "bar" {
t.Errorf("FromRegistry modified the given address; should be pure function")
}
if got, want := gotAddr.Subdir, "bar"; got != want {
t.Errorf("wrong resolved subdir\ngot: %s\nwant: %s", got, want)
}
})
}
func TestParseModuleSourceRemote(t *testing.T) {
tests := map[string]struct {
input string
wantString string
wantForDisplay string
wantErr string
}{
"git over HTTPS, URL-style, query parameters": {
// Query parameters should be correctly appended after the Package
input: `git::https://example.com/code/baz.git?otherthing=blah`,
wantString: `git::https://example.com/code/baz.git?otherthing=blah`,
wantForDisplay: `git::https://example.com/code/baz.git?otherthing=blah`,
},
"git over HTTPS, URL-style, subdir, query parameters": {
// Query parameters should be correctly appended after the Package and Subdir
input: `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
wantString: `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
wantForDisplay: `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
remote, err := parseModuleSourceRemote(test.input)
if test.wantErr != "" {
switch {
case err == nil:
t.Errorf("unexpected success\nwant error: %s", test.wantErr)
case err.Error() != test.wantErr:
t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if got, want := remote.String(), test.wantString; got != want {
t.Errorf("wrong String() result\ngot: %s\nwant: %s", got, want)
}
if got, want := remote.ForDisplay(), test.wantForDisplay; got != want {
t.Errorf("wrong ForDisplay() result\ngot: %s\nwant: %s", got, want)
}
})
}
}
func TestParseModuleSourceRegistry(t *testing.T) {
// We test parseModuleSourceRegistry alone here, in addition to testing
// it indirectly as part of TestParseModuleSource, because general
// module parsing unfortunately eats all of the error situations from
// registry passing by falling back to trying for a direct remote package
// address.
// Historical note: These test cases were originally derived from the
// ones in the old internal/registry/regsrc package that the
// ModuleSourceRegistry type is replacing. That package had the notion
// of "normalized" addresses as separate from the original user input,
// but this new implementation doesn't try to preserve the original
// user input at all, and so the main string output is always normalized.
//
// That package also had some behaviors to turn the namespace, name, and
// remote system portions into lowercase, but apparently we didn't
// actually make use of that in the end and were preserving the case
// the user provided in the input, and so for backward compatibility
// we're continuing to do that here, at the expense of now making the
// "ForDisplay" output case-preserving where its predecessor in the
// old package wasn't. The main OpenTofu Registry at registry.opentofu.org
// is itself case-insensitive anyway, so our case-preserving here is
// entirely for the benefit of existing third-party registry
// implementations that might be case-sensitive, which we must remain
// compatible with now.
tests := map[string]struct {
input string
wantString string
wantForDisplay string
wantForProtocol string
wantErr string
}{
"public registry": {
input: `hashicorp/consul/aws`,
wantString: `registry.opentofu.org/hashicorp/consul/aws`,
wantForDisplay: `hashicorp/consul/aws`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry with subdir": {
input: `hashicorp/consul/aws//foo`,
wantString: `registry.opentofu.org/hashicorp/consul/aws//foo`,
wantForDisplay: `hashicorp/consul/aws//foo`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry using explicit hostname": {
input: `registry.opentofu.org/hashicorp/consul/aws`,
wantString: `registry.opentofu.org/hashicorp/consul/aws`,
wantForDisplay: `hashicorp/consul/aws`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry with mixed case names": {
input: `HashiCorp/Consul/aws`,
wantString: `registry.opentofu.org/HashiCorp/Consul/aws`,
wantForDisplay: `HashiCorp/Consul/aws`,
wantForProtocol: `HashiCorp/Consul/aws`,
},
"private registry with non-standard port": {
input: `Example.com:1234/HashiCorp/Consul/aws`,
wantString: `example.com:1234/HashiCorp/Consul/aws`,
wantForDisplay: `example.com:1234/HashiCorp/Consul/aws`,
wantForProtocol: `HashiCorp/Consul/aws`,
},
"private registry with IDN hostname": {
input: `Испытание.com/HashiCorp/Consul/aws`,
wantString: `испытание.com/HashiCorp/Consul/aws`,
wantForDisplay: `испытание.com/HashiCorp/Consul/aws`,
wantForProtocol: `HashiCorp/Consul/aws`,
},
"private registry with IDN hostname and non-standard port": {
input: `Испытание.com:1234/HashiCorp/Consul/aws//Foo`,
wantString: `испытание.com:1234/HashiCorp/Consul/aws//Foo`,
wantForDisplay: `испытание.com:1234/HashiCorp/Consul/aws//Foo`,
wantForProtocol: `HashiCorp/Consul/aws`,
},
"invalid hostname": {
input: `---.com/HashiCorp/Consul/aws`,
wantErr: `invalid module registry hostname "---.com"; internationalized domain names must be given as direct unicode characters, not in punycode`,
},
"hostname with only one label": {
// This was historically forbidden in our initial implementation,
// so we keep it forbidden to avoid newly interpreting such
// addresses as registry addresses rather than remote source
// addresses.
input: `foo/var/baz/qux`,
wantErr: `invalid module registry hostname: must contain at least one dot`,
},
"invalid target system characters": {
input: `foo/var/no-no-no`,
wantErr: `invalid target system "no-no-no": must be between one and 64 ASCII letters or digits`,
},
"invalid target system length": {
input: `foo/var/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah`,
wantErr: `invalid target system "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah": must be between one and 64 ASCII letters or digits`,
},
"invalid namespace": {
input: `boop!/var/baz`,
wantErr: `invalid namespace "boop!": must be between one and 64 characters, including ASCII letters, digits, dashes, and underscores, where dashes and underscores may not be the prefix or suffix`,
},
"missing part with explicit hostname": {
input: `foo.com/var/baz`,
wantErr: `source address must have three more components after the hostname: the namespace, the name, and the target system`,
},
"errant query string": {
input: `foo/var/baz?otherthing`,
wantErr: `module registry addresses may not include a query string portion`,
},
"github.com": {
// We don't allow using github.com like a module registry because
// that conflicts with the historically-supported shorthand for
// installing directly from GitHub-hosted git repositories.
input: `github.com/HashiCorp/Consul/aws`,
wantErr: `can't use "github.com" as a module registry host, because it's reserved for installing directly from version control repositories`,
},
"bitbucket.org": {
// We don't allow using bitbucket.org like a module registry because
// that conflicts with the historically-supported shorthand for
// installing directly from BitBucket-hosted git repositories.
input: `bitbucket.org/HashiCorp/Consul/aws`,
wantErr: `can't use "bitbucket.org" as a module registry host, because it's reserved for installing directly from version control repositories`,
},
"local path from current dir": {
// Can't use a local path when we're specifically trying to parse
// a _registry_ source address.
input: `./boop`,
wantErr: `can't use local directory "./boop" as a module registry address`,
},
"local path from parent dir": {
// Can't use a local path when we're specifically trying to parse
// a _registry_ source address.
input: `../boop`,
wantErr: `can't use local directory "../boop" as a module registry address`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
addrI, err := ParseModuleSourceRegistry(test.input)
if test.wantErr != "" {
switch {
case err == nil:
t.Errorf("unexpected success\nwant error: %s", test.wantErr)
case err.Error() != test.wantErr:
t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
addr, ok := addrI.(ModuleSourceRegistry)
if !ok {
t.Fatalf("wrong address type %T; want %T", addrI, addr)
}
if got, want := addr.String(), test.wantString; got != want {
t.Errorf("wrong String() result\ngot: %s\nwant: %s", got, want)
}
if got, want := addr.ForDisplay(), test.wantForDisplay; got != want {
t.Errorf("wrong ForDisplay() result\ngot: %s\nwant: %s", got, want)
}
if got, want := addr.Package.ForRegistryProtocol(), test.wantForProtocol; got != want {
t.Errorf("wrong ForRegistryProtocol() result\ngot: %s\nwant: %s", got, want)
}
})
}
}
func testDataAbsolutePath() (absolutePath string, modulePackage string) {
absolutePath = "/tmp/foo/example"
modulePackage = "file:///tmp/foo/example"
if runtime.GOOS == "windows" {
absolutePath = "C:\\tmp\\foo\\example"
modulePackage = "C:\\tmp\\foo\\example"
}
return
}
func testDataAbsolutePathSubdir() (absolutePath string, modulePackage string) {
absolutePath = "/tmp/foo//example"
modulePackage = "file:///tmp/foo"
if runtime.GOOS == "windows" {
absolutePath = "C:\\tmp\\foo//example"
modulePackage = "C:\\tmp\\foo"
}
return
}