Files
opentf/internal/getproviders/hash_test.go
Martin Atkins 55855fca70 getproviders: Unify package authentication with hash lock selection
As discussed in opentofu/opentofu#2656, this consolidates the two concerns
of the PackageAuthentication interface into a single function that deals
both with package authentication _and_ with reporting all of the package
hashes that were used to make the authentication decision.

This means that any .zip archive that OpenTofu directly verifies during
installation can now have its hash recorded in the dependency lock file
even if that package didn't come from the provider's origin registry, which
is beneficial when the first installation of a provider comes from a
secondary ("mirror") source because it creates an additional hook by which
that dependency lock file entry can be "upgraded" to be complete in a
future "tofu init" run against the origin registry, or by the
"tofu providers lock" command.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-04-14 08:31:40 -07:00

327 lines
7.9 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 getproviders
import (
"maps"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/opentofu/opentofu/internal/collections"
)
func TestParseHash(t *testing.T) {
tests := []struct {
Input string
Want Hash
WantErr string
}{
{
Input: "h1:foo",
Want: HashScheme1.New("foo"),
},
{
Input: "zh:bar",
Want: HashSchemeZip.New("bar"),
},
{
// A scheme we don't know is considered valid syntax, it just won't match anything.
Input: "unknown:baz",
Want: HashScheme("unknown:").New("baz"),
},
{
// A scheme with an empty value is weird, but allowed.
Input: "unknown:",
Want: HashScheme("unknown:").New(""),
},
{
Input: "",
WantErr: "hash string must start with a scheme keyword followed by a colon",
},
{
// A naked SHA256 hash in hex format is not sufficient
Input: "1e5f7a5f3ade7b8b1d1d59c5cea2e1a2f8d2f8c3f41962dbbe8647e222be8239",
WantErr: "hash string must start with a scheme keyword followed by a colon",
},
{
// An empty scheme is not allowed
Input: ":blah",
WantErr: "hash string must start with a scheme keyword followed by a colon",
},
}
for _, test := range tests {
t.Run(test.Input, func(t *testing.T) {
got, err := ParseHash(test.Input)
if test.WantErr != "" {
if err == nil {
t.Fatalf("want error: %s", test.WantErr)
}
if got, want := err.Error(), test.WantErr; got != want {
t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if got != test.Want {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}
func TestMergeHashDisposition(t *testing.T) {
tests := map[string]struct {
a, b *HashDisposition
want *HashDisposition
}{
"empties": {
a: &HashDisposition{},
b: &HashDisposition{},
want: &HashDisposition{},
},
"empty with VerifiedLocally": {
a: &HashDisposition{
VerifiedLocally: true,
},
b: &HashDisposition{},
want: &HashDisposition{
VerifiedLocally: true,
},
},
"empty with ReportedByRegistry": {
a: &HashDisposition{
ReportedByRegistry: true,
},
b: &HashDisposition{},
want: &HashDisposition{
ReportedByRegistry: true,
},
},
"empty with one GPG key": {
a: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123"),
},
b: &HashDisposition{},
want: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123"),
},
},
"many GPG keys": {
a: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123", "def456"),
},
b: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("def456", "ghi789"),
},
want: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123", "def456", "ghi789"),
},
},
"VerifiedLocally with ReportedByRegistry": {
a: &HashDisposition{
ReportedByRegistry: true,
},
b: &HashDisposition{
VerifiedLocally: true,
},
want: &HashDisposition{
ReportedByRegistry: true,
VerifiedLocally: true,
},
},
"VerifiedLocally with itself": {
a: &HashDisposition{
VerifiedLocally: true,
},
b: &HashDisposition{
VerifiedLocally: true,
},
want: &HashDisposition{
VerifiedLocally: true,
},
},
"ReportedByRegistry with itself": {
a: &HashDisposition{
ReportedByRegistry: true,
},
b: &HashDisposition{
ReportedByRegistry: true,
},
want: &HashDisposition{
ReportedByRegistry: true,
},
},
"Everything at once": {
a: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("def456", "ghi789"),
ReportedByRegistry: true,
},
b: &HashDisposition{
VerifiedLocally: true,
},
want: &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("def456", "ghi789"),
ReportedByRegistry: true,
VerifiedLocally: true,
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
// MergeHashDisposition is supposed to be commutative, so
// we'll test each case in both orders and expect an
// equivalent result in each case.
t.Run("a,b", func(t *testing.T) {
got := MergeHashDisposition(test.a, test.b)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Error("wrong result\n" + diff)
}
})
t.Run("b,a", func(t *testing.T) {
got := MergeHashDisposition(test.b, test.a)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Error("wrong result\n" + diff)
}
})
})
}
}
func TestHashDispositionsMerge(t *testing.T) {
// HashDispositions.Merge delegates to MergeHashDisposition when both
// arguments refer to the same hash. We already have lots of tests for
// MergeHashDisposition in TestMergeHashDisposition, so this test
// intentionally does not duplicate all of those cases and focuses
// only on the different cases that HashDispositions.Merge is directly
// concernd with.
tests := map[string]struct {
a, b HashDispositions
want HashDispositions
}{
"empties": {
a: HashDispositions{},
b: HashDispositions{},
want: HashDispositions{},
},
"one into empty": {
a: HashDispositions{},
b: HashDispositions{
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
},
},
want: HashDispositions{
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
},
},
},
"independent hashes": {
a: HashDispositions{
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
},
},
b: HashDispositions{
Hash("test:bar"): &HashDisposition{
VerifiedLocally: true,
},
},
want: HashDispositions{
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
},
Hash("test:bar"): &HashDisposition{
VerifiedLocally: true,
},
},
},
"overlapping hashes": {
a: HashDispositions{
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
},
},
b: HashDispositions{
Hash("test:foo"): &HashDisposition{
VerifiedLocally: true,
},
},
want: HashDispositions{
// This should be the result of MergeHashDispositions
// on the two different entries for test:foo.
Hash("test:foo"): &HashDisposition{
ReportedByRegistry: true,
VerifiedLocally: true,
},
},
},
"mix of overlapping and independent": {
a: HashDispositions{
Hash("test:foo"): &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123"),
},
Hash("test:bar"): &HashDisposition{
ReportedByRegistry: true,
},
},
b: HashDispositions{
Hash("test:foo"): &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("def456"),
},
Hash("test:baz"): &HashDisposition{
VerifiedLocally: true,
},
},
want: HashDispositions{
Hash("test:foo"): &HashDisposition{
SignedByGPGKeyIDs: collections.NewSet("abc123", "def456"),
},
Hash("test:bar"): &HashDisposition{
ReportedByRegistry: true,
},
Hash("test:baz"): &HashDisposition{
VerifiedLocally: true,
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
// HashDispositions.Merge is supposed to be commutative,
// so we'll test each case in both orders and expect an
// equivalent result in each case.
t.Run("a,b", func(t *testing.T) {
// We'll make a shallow copy of test.a so that we
// aren't directly modifying the test table, since
// otherwise we'll pollute the input to the
// opposite order test below.
got := maps.Clone(test.a)
got.Merge(test.b)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Error("wrong result\n" + diff)
}
})
t.Run("b,a", func(t *testing.T) {
got := maps.Clone(test.b)
got.Merge(test.a)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Error("wrong result\n" + diff)
}
})
})
}
}