From 71c2361dfca1ba083eec3c5d24df5970bf379123 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 08:17:21 -0700 Subject: [PATCH] add unit test --- go.mod | 1 + go.sum | 6 + pkg/cmd/attestation/api/mock_client.go | 11 ++ pkg/cmd/attestation/test/data/a.zip | 28 ++++ pkg/cmd/attestation/test/data/data.go | 12 ++ .../test/data/github_release_bundle.json | 24 +++ pkg/cmd/release/shared/fetch.go | 8 + pkg/cmd/release/verify-asset/verify-asset.go | 28 +++- .../release/verify-asset/verify-asset_test.go | 158 ++++++++++++++++++ pkg/cmd/release/verify/verify.go | 16 +- pkg/cmd/release/verify/verify_test.go | 142 ++++++++++++++++ 11 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/a.zip create mode 100644 pkg/cmd/attestation/test/data/github_release_bundle.json create mode 100644 pkg/cmd/release/verify-asset/verify-asset_test.go create mode 100644 pkg/cmd/release/verify/verify_test.go diff --git a/go.mod b/go.mod index f95c8a7c2..7ffaf3cc9 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 + gotest.tools/v3 v3.0.3 ) require ( diff --git a/go.sum b/go.sum index e0ecad6a7..564042eaf 100644 --- a/go.sum +++ b/go.sum @@ -243,6 +243,7 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= @@ -410,6 +411,7 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -483,6 +485,7 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -560,6 +563,7 @@ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCR golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -597,11 +601,13 @@ golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index b6062b39f..4b4f06eff 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -6,6 +6,13 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" ) +func makeTestReleaseAttestation() Attestation { + return Attestation{ + Bundle: data.GitHubReleaseBundle(nil), + BundleURL: "https://example.com", + } +} + func makeTestAttestation() Attestation { return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} } @@ -26,8 +33,12 @@ func (m MockClient) GetTrustDomain() (string, error) { func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() + att3 := makeTestReleaseAttestation() attestations := []*Attestation{&att1, &att2} if params.PredicateType != "" { + if params.PredicateType == "https://in-toto.io/attestation/release/v0.1" { + attestations = append(attestations, &att3) + } return FilterAttestations(params.PredicateType, attestations) } diff --git a/pkg/cmd/attestation/test/data/a.zip b/pkg/cmd/attestation/test/data/a.zip new file mode 100644 index 000000000..f4595ef44 --- /dev/null +++ b/pkg/cmd/attestation/test/data/a.zip @@ -0,0 +1,28 @@ +a # frozen_string_literal: true + +source "https://rubygems.org" + +source "https://rubygems.pkg.github.com/github" do + gem "entitlements-aad-plugin", "~> 1.0" + gem "entitlements-app", "~> 1.2" + gem "entitlements-github-plugin", "~> 1.2" + gem "entitlements-gitrepo-auditor-plugin", "~> 1.0" + gem "entitlements-jit-github-plugin", "~> 1.0" + gem "entitlements-lib", "~> 0.2" + gem "entitlements-stafftools-plugin", "~> 1.0" +end + +group :development do + gem "base64", "~> 0.2.0" + gem "irb", "~> 1.15" + gem "pry", "~> 0.14" + gem "pry-byebug", "~> 3.9" + gem "pry-rescue", "~> 1.6" + gem "rspec", "~> 3.13" + gem "rubocop", "~> 1.71" + gem "rubocop-github", "~> 0.20.0" + gem "rubocop-performance" + gem "rubocop-rspec", "~> 3.4.0" + gem "simplecov", "~> 0.21" + gem "simplecov-erb", "~> 1.0.0" +end diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index ef3c35c20..223d6f22e 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -10,6 +10,9 @@ import ( //go:embed sigstore-js-2.1.0-bundle.json var SigstoreBundleRaw []byte +//go:embed github_release_bundle.json +var GitHubReleaseBundleRaw []byte + // SigstoreBundle returns a test sigstore-go bundle.Bundle func SigstoreBundle(t *testing.T) *bundle.Bundle { b := &bundle.Bundle{} @@ -19,3 +22,12 @@ func SigstoreBundle(t *testing.T) *bundle.Bundle { } return b } + +func GitHubReleaseBundle(t *testing.T) *bundle.Bundle { + b := &bundle.Bundle{} + err := b.UnmarshalJSON(GitHubReleaseBundleRaw) + if err != nil { + t.Fatalf("failed to unmarshal GitHub release bundle: %v", err) + } + return b +} diff --git a/pkg/cmd/attestation/test/data/github_release_bundle.json b/pkg/cmd/attestation/test/data/github_release_bundle.json new file mode 100644 index 000000000..ae8dd1b56 --- /dev/null +++ b/pkg/cmd/attestation/test/data/github_release_bundle.json @@ -0,0 +1,24 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" + } + ] + }, + "certificate": { + "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 5fea30b7c..4e1be87e3 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -281,3 +281,11 @@ func StubFetchRelease(t *testing.T, reg *httpmock.Registry, owner, repoName, tag ) } } + +func StubFetchRefSHA(t *testing.T, reg *httpmock.Registry, owner, repoName, tagName, sha string) { + path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", owner, repoName, tagName) + reg.Register( + httpmock.REST("GET", path), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": "%s"}}`, sha)), + ) +} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index ddefdf5be..0c4443d04 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -27,14 +27,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Use: "verify-asset ", Short: "Verify that a given asset originated from a specific GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(2), + Args: cobra.MaximumNArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 2 { - return cmdutil.FlagErrorf("You must specify a tag and a file path") - } - tagName := args[0] - assetFilePath := args[1] + if len(args) == 2 { + opts.TagName = args[0] + opts.AssetFilePath = args[1] + } else if len(args) == 1 { + opts.AssetFilePath = args[0] + } else { + return cmdutil.FlagErrorf("you must specify an asset filepath") + } httpClient, err := f.HttpClient() if err != nil { @@ -53,8 +56,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) } *opts = attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: assetFilePath, + TagName: opts.TagName, + AssetFilePath: opts.AssetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, @@ -114,6 +117,15 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() + + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + fileName := getFileName(opts.AssetFilePath) // calculate the digest of the file diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go new file mode 100644 index 000000000..eb333fc06 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -0,0 +1,158 @@ +package verifyasset + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/internal/ghrepo" + + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/httpmock" +) + +func TestNewCmdVerifyAsset_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantFile string + wantErr string + }{ + { + name: "valid args", + args: []string{"v1.2.3", "../../attestation/test/data/a.zip"}, + wantTag: "v1.2.3", + wantFile: "../../attestation/test/data/a.zip", + }, + { + name: "valid flag with no tag", + + args: []string{"../../attestation/test/data/a.zip"}, + wantFile: "../../attestation/test/data/a.zip", + }, + { + name: "no args", + args: []string{}, + wantErr: "you must specify an asset filepath", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerifyAsset(f, func(o *attestation.AttestOptions) error { + opts = o + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantTag, opts.TagName) + assert.Equal(t, tt.wantFile, opts.AssetFilePath) + } + }) + } +} + +func Test_verifyAssetRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/a.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + err = verifyAssetRun(opts) + require.NoError(t, err) +} + +func Test_verifyAssetRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &attestation.AttestOptions{ + TagName: "v1.2.3", + AssetFilePath: "artifact.tgz", + Repo: "owner/repo", + Limit: 10, + Logger: io.NewHandler(ios), + IO: ios, + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + EC: verification.EnforcementCriteria{}, + } + + err := verifyAssetRun(opts) + require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") +} + +func Test_getFileName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"foo/bar/baz.txt", "baz.txt"}, + {"baz.txt", "baz.txt"}, + {"/tmp/foo.tar.gz", "foo.tar.gz"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getFileName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 96c33c50b..76a5cd773 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -28,14 +28,12 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Use: "verify []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return cmdutil.FlagErrorf("You must specify a tag") + if len(args) > 0 { + opts.TagName = args[0] } - opts.TagName = args[0] - httpClient, err := f.HttpClient() if err != nil { return err @@ -115,6 +113,14 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go new file mode 100644 index 000000000..71a282aa2 --- /dev/null +++ b/pkg/cmd/release/verify/verify_test.go @@ -0,0 +1,142 @@ +package verify + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" + "gotest.tools/v3/assert" +) + +func TestNewCmdVerify_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantErr string + }{ + { + name: "valid tag arg", + args: []string{"v1.2.3"}, + wantTag: "v1.2.3", + }, + { + name: "no tag arg", + args: []string{}, + wantTag: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerify(f, func(o *attestation.AttestOptions) error { + opts = o + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + require.NoError(t, err) + assert.Equal(t, tt.wantTag, opts.TagName) + }) + } +} + +func Test_verifyRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.NoError(t, err) +} + +func Test_verifyRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewFailTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.Error(t, err, "failed to fetch attestations from owner/repo") +}