opentofu/internal/addrs/module_source_test.go
Martin Atkins affe2c3295 addrs: Expose the registry address parser's error messages
Previously we ended up losing all of the error message detail produced by
the registry address parser, because we treated any registry address
failure as cause to parse the address as a go-getter-style remote address
instead.

That led to terrible feedback in the situation where the user _was_
trying to write a module address but it was invalid in some way.

Although we can't really tighten this up in the default case due to our
compatibility promises, it's never been valid to use the "version"
argument with anything other than a registry address and so as a
compromise here we'll use the presence of "version" as a heuristic for
user intent to parse the source address as a registry address, and thus
we can return a registry-address-specific error message in that case and
thus give more direct feedback about what was wrong.

This unfortunately won't help someone trying to install from the registry
_without_ a version constraint, but I didn't want to let perfect be the
enemy of the good here, particularly since we recommend using version
constraints with registry modules anyway; indeed, that's one of the main
benefits of using a registry rather than a remote source directly.
2021-11-30 15:46:16 -08:00

574 lines
21 KiB
Go

package addrs
import (
"testing"
"github.com/google/go-cmp/cmp"
svchost "github.com/hashicorp/terraform-svchost"
)
func TestParseModuleSource(t *testing.T) {
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{
PackageAddr: ModuleRegistryPackage{
Host: svchost.Hostname("registry.terraform.io"),
Namespace: "hashicorp",
Name: "subnets",
TargetSystem: "cidr",
},
Subdir: "",
},
},
"main registry implied, subdir": {
input: "hashicorp/subnets/cidr//examples/foo",
want: ModuleSourceRegistry{
PackageAddr: ModuleRegistryPackage{
Host: svchost.Hostname("registry.terraform.io"),
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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: ModulePackage("git://example.com/code/baz.git"),
},
},
"git protocol, URL-style, subdir": {
input: "git://example.com/code/baz.git//bleep/bloop",
want: ModuleSourceRemote{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: ModulePackage("git::https://example.com/code/baz.git"),
Subdir: "bleep/bloop",
},
},
"git over SSH, URL-style": {
input: "git::ssh://git@example.com/code/baz.git",
want: ModuleSourceRemote{
PackageAddr: 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{
PackageAddr: 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
PackageAddr: 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
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: ModulePackage("s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"),
},
},
"HTTP URL": {
input: "http://example.com/module",
want: ModuleSourceRemote{
PackageAddr: ModulePackage("http://example.com/module"),
},
},
"HTTPS URL": {
input: "https://example.com/module",
want: ModuleSourceRemote{
PackageAddr: ModulePackage("https://example.com/module"),
},
},
"HTTPS URL, archive file": {
input: "https://example.com/module.zip",
want: ModuleSourceRemote{
PackageAddr: ModulePackage("https://example.com/module.zip"),
},
},
"HTTPS URL, forced archive file": {
input: "https://example.com/module?archive=tar",
want: ModuleSourceRemote{
PackageAddr: 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.
PackageAddr: 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: "/tmp/foo/example",
want: ModuleSourceRemote{
PackageAddr: ModulePackage("file:///tmp/foo/example"),
},
},
"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: "/tmp/foo//example",
want: ModuleSourceRemote{
PackageAddr: ModulePackage("file:///tmp/foo"),
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: `Terraform 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.
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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{
PackageAddr: 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 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 Terraform Registry at registry.terraform.io
// 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.terraform.io/hashicorp/consul/aws`,
wantForDisplay: `hashicorp/consul/aws`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry with subdir": {
input: `hashicorp/consul/aws//foo`,
wantString: `registry.terraform.io/hashicorp/consul/aws//foo`,
wantForDisplay: `hashicorp/consul/aws//foo`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry using explicit hostname": {
input: `registry.terraform.io/hashicorp/consul/aws`,
wantString: `registry.terraform.io/hashicorp/consul/aws`,
wantForDisplay: `hashicorp/consul/aws`,
wantForProtocol: `hashicorp/consul/aws`,
},
"public registry with mixed case names": {
input: `HashiCorp/Consul/aws`,
wantString: `registry.terraform.io/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.PackageAddr.ForRegistryProtocol(), test.wantForProtocol; got != want {
t.Errorf("wrong ForRegistryProtocol() result\ngot: %s\nwant: %s", got, want)
}
})
}
}