diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39711e79..3a34dafe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,10 @@ jobs: - name: Run tests run: go test -tags e2e -coverpkg=./... -coverprofile=coverage.txt -v -race ./test/e2e + env: + # Domain must be available in the account running the tests. This domain is + # available in the account running the public integration tests. + CERT_DOMAIN: hc-integrations-test.de - name: Upload coverage reports to Codecov if: > diff --git a/test/e2e/certificate_test.go b/test/e2e/certificate_test.go new file mode 100644 index 00000000..bbbe979f --- /dev/null +++ b/test/e2e/certificate_test.go @@ -0,0 +1,208 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +const fingerprintRegex = `[0-9A-F]{2}(:[0-9A-F]{2}){31}` + +func TestCertificate(t *testing.T) { + t.Parallel() + + t.Run("uploaded", func(t *testing.T) { + tmpDir := t.TempDir() + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) + certPath, keyPath := path.Join(tmpDir, "cert.pem"), path.Join(tmpDir, "key.pem") + err := generateCertificate(certPath, keyPath, notBefore, notAfter) + require.NoError(t, err) + + certName := withSuffix("test-certificate-uploaded") + certID, err := createCertificate(t, certName, hcloud.CertificateTypeUploaded, "--cert-file", certPath, "--key-file", keyPath) + require.NoError(t, err) + + runCertificateTestSuite(t, certName, certID, hcloud.CertificateTypeUploaded, "example.com") + }) + + t.Run("managed", func(t *testing.T) { + certDomain := os.Getenv("CERT_DOMAIN") + if certDomain == "" { + t.Skip("Skipping because CERT_DOMAIN is not set") + } + + // random subdomain + certDomain = fmt.Sprintf("%s.%s", randomHex(4), certDomain) + + certName := withSuffix("test-certificate-managed") + certID, err := createCertificate(t, certName, hcloud.CertificateTypeManaged, "--type", "managed", "--domain", certDomain) + require.NoError(t, err) + + runCertificateTestSuite(t, certName, certID, hcloud.CertificateTypeManaged, certDomain) + }) +} + +func runCertificateTestSuite(t *testing.T, certName string, certID int64, certType hcloud.CertificateType, domainName string) { + t.Helper() + + t.Run("add-label", func(t *testing.T) { + t.Run("non-existing", func(t *testing.T) { + out, err := runCommand(t, "certificate", "add-label", "non-existing-certificate", "foo=bar") + require.EqualError(t, err, "certificate not found: non-existing-certificate") + assert.Empty(t, out) + }) + + t.Run("1", func(t *testing.T) { + out, err := runCommand(t, "certificate", "add-label", strconv.FormatInt(certID, 10), "foo=bar") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo added to certificate %d\n", certID), out) + }) + + t.Run("2", func(t *testing.T) { + out, err := runCommand(t, "certificate", "add-label", strconv.FormatInt(certID, 10), "baz=qux") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz added to certificate %d\n", certID), out) + }) + }) + + t.Run("update-name", func(t *testing.T) { + certName = withSuffix("new-test-certificate-" + string(certType)) + + out, err := runCommand(t, "certificate", "update", strconv.FormatInt(certID, 10), "--name", certName) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("certificate %d updated\n", certID), out) + }) + + t.Run("remove-label", func(t *testing.T) { + out, err := runCommand(t, "certificate", "remove-label", certName, "baz") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz removed from certificate %d\n", certID), out) + }) + + t.Run("list", func(t *testing.T) { + out, err := runCommand(t, "certificate", "list", "-o=columns=id,name,labels,type,created,"+ + "not_valid_before,not_valid_after,domain_names,fingerprint,issuance_status,renewal_status,age") + require.NoError(t, err) + + labels := []string{"foo=bar"} + if certType == hcloud.CertificateTypeManaged { + labels = append([]string{"HC-Use-Staging-CA=true"}, labels...) + } + + assert.Regexp(t, + NewRegex().Start(). + SeparatedByWhitespace( + "ID", "NAME", "LABELS", "TYPE", "CREATED", "NOT VALID BEFORE", "NOT VALID AFTER", + "DOMAIN NAMES", "FINGERPRINT", "ISSUANCE STATUS", "RENEWAL STATUS", "AGE", + ).Newline(). + Lit(strconv.FormatInt(certID, 10)).Whitespace(). + Lit(certName).Whitespace(). + Lit(strings.Join(labels, ", ")).Whitespace(). + Lit(string(certType)).Whitespace(). + UnixDate().Whitespace(). + UnixDate().Whitespace(). + UnixDate().Whitespace(). + Lit(domainName).Whitespace(). + Raw(fingerprintRegex).Whitespace(). + OneOf("completed", "n/a").Whitespace(). + Lit("n/a").Whitespace(). + Age().Newline(). + End(), + out, + ) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "certificate", "describe", strconv.FormatInt(certID, 10)) + require.NoError(t, err) + + regex := NewRegex().Start(). + Lit("ID:").Whitespace().Int().Newline(). + Lit("Name:").Whitespace().Lit(certName).Newline(). + Lit("Type:").Whitespace().Lit(string(certType)).Newline(). + Lit("Fingerprint:").Whitespace().Raw(fingerprintRegex).Newline(). + Lit("Created:").Whitespace().UnixDate().Lit(" (").HumanizeTime().Lit(")").Newline(). + Lit("Not valid before:").Whitespace().UnixDate().Lit(" (").HumanizeTime().Lit(")").Newline(). + Lit("Not valid after:").Whitespace().UnixDate().Lit(" (").HumanizeTime().Lit(")").Newline() + + if certType == hcloud.CertificateTypeManaged { + regex = regex. + Lit("Status:").Newline(). + Lit(" Issuance:").Whitespace().Lit("completed").Newline(). + Lit(" Renewal:").Whitespace().Lit("unavailable").Newline() + } + + regex = regex. + Lit("Domain names:").Newline(). + Lit(" - ").Lit(domainName).Newline(). + Lit("Labels:").Newline() + + if certType == hcloud.CertificateTypeManaged { + regex = regex.Lit(" HC-Use-Staging-CA:").Whitespace().Lit("true").Newline() + } + + regex = regex. + Lit(" foo:").Whitespace().Lit("bar").Newline(). + Lit("Used By:").Newline(). + Lit(" Certificate unused").Newline(). + End() + + assert.Regexp(t, regex, out) + }) + + t.Run("retry", func(t *testing.T) { + out, err := runCommand(t, "certificate", "retry", strconv.FormatInt(certID, 10)) + assert.Empty(t, out) + require.Error(t, err) + assert.Regexp(t, `certificate not retryable \(unsupported_error, [0-9a-f]+\)`, err.Error()) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "certificate", "delete", strconv.FormatInt(certID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("certificate %d deleted\n", certID), out) + }) +} + +func createCertificate(t *testing.T, name string, certificateType hcloud.CertificateType, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _ = client.Certificate.Delete(context.Background(), &hcloud.Certificate{Name: name}) + }) + + if certificateType == hcloud.CertificateTypeManaged { + args = append([]string{"--label", "HC-Use-Staging-CA=true"}, args...) + } + + out, err := runCommand(t, append([]string{"certificate", "create", "--name", name, "--type", string(certificateType)}, args...)...) + if err != nil { + return 0, err + } + + if !assert.Regexp(t, `^Certificate [0-9]+ created\n$`, out) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[12:len(out)-9], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _ = client.Certificate.Delete(context.Background(), &hcloud.Certificate{ID: id}) + }) + return id, nil +} diff --git a/test/e2e/certificate_util.go b/test/e2e/certificate_util.go new file mode 100644 index 00000000..2e042413 --- /dev/null +++ b/test/e2e/certificate_util.go @@ -0,0 +1,80 @@ +//go:build e2e + +package e2e + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "time" +) + +// Adapted from https://go.dev/src/crypto/tls/generate_cert.go + +func generateCertificate(certFile, keyFile string, notBefore, notAfter time.Time) error { + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: []string{ + "example.com", + }, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return err + } + + certOut, err := os.Create(certFile) + if err != nil { + return err + } + defer func() { + _ = certOut.Close() + }() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return err + } + + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { + _ = keyOut.Close() + }() + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return err + } + if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + return err + } + + return nil +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index cafed3c5..fb26a965 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -55,12 +55,15 @@ func runCommand(t *testing.T, args ...string) (string, error) { return buf.String(), err } -func withSuffix(s string) string { - b := make([]byte, 4) +func randomHex(n int) string { + b := make([]byte, n) _, err := rand.Read(b) if err != nil { panic(err) } - suffix := hex.EncodeToString(b) - return fmt.Sprintf("%s-%s", s, suffix) + return hex.EncodeToString(b) +} + +func withSuffix(s string) string { + return fmt.Sprintf("%s-%s", s, randomHex(4)) } diff --git a/test/e2e/util.go b/test/e2e/util.go index 4e4cb99a..fd5e8f58 100644 --- a/test/e2e/util.go +++ b/test/e2e/util.go @@ -94,7 +94,7 @@ func (r RegexBuilder) Age() RegexBuilder { } func (r RegexBuilder) HumanizeTime() RegexBuilder { - return r.OneOf(`now`, `[0-9]+ (?:seconds?|minutes?|hours?|days?|months?|years?) ago`) + return r.OneOf(`now`, `[0-9]+ (?:seconds?|minutes?|hours?|days?|months?|years?) (ago|from now)`) } func (r RegexBuilder) Whitespace() RegexBuilder {