Repository: hetznercloud/hcloud-go
Pull request #167 (2021-03-30 08:24:09)

Managed certificate support


Patch:
diff --git a/.golangci.yaml b/.golangci.yaml
index 0674a26..3e86fc8 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -2,6 +2,24 @@
 linters-settings:
   errcheck:
     exclude: ./.errcheck_excludes.txt
+  exhaustive:
+    default-signifies-exhaustive: true
+  gomodguard:
+    blocked:
+      modules:
+        - github.com/tj/assert:
+            recommendations:
+              - github.com/stretchr/testify/assert
+            reason: |
+              One assertion library is enough and we use testify/assert
+              everywhere.
+        - github.com/magiconair/properties:
+            recommendations:
+              - github.com/stretchr/testify/assert
+            reason: >
+              We do not currently need to parse properties files. At the same
+              time this module has an assert package which tends to get
+              imported by accident. It is therefore blocked.
   misspell:
     locale: "US"
 
@@ -12,9 +30,11 @@ linters:
     - deadcode
     - depguard
     - errcheck
+    - exhaustive
     - gocritic
     - goimports
     - golint
+    - gomodguard
     - gosec
     - gosimple
     - govet
diff --git a/go.mod b/go.mod
index 7f2a7b2..82cb8c7 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module github.com/hetznercloud/hcloud-go
 
 go 1.16
 
-require github.com/google/go-cmp v0.5.2
+require (
+	github.com/google/go-cmp v0.5.0
+	github.com/stretchr/testify v1.7.0
+)
diff --git a/go.sum b/go.sum
index 2b51a98..c9ecce4 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,17 @@
 github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/hcloud/action.go b/hcloud/action.go
index e3e2091..622f7bf 100644
--- a/hcloud/action.go
+++ b/hcloud/action.go
@@ -220,6 +220,8 @@ func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Acti
 
 			for _, a := range as {
 				switch a.Status {
+				case ActionStatusRunning:
+					continue
 				case ActionStatusSuccess:
 					delete(watchIDs, a.ID)
 					successIDs := append(successIDs, a.ID)
diff --git a/hcloud/certificate.go b/hcloud/certificate.go
index a6cb5cc..771a67e 100644
--- a/hcloud/certificate.go
+++ b/hcloud/certificate.go
@@ -13,17 +13,81 @@ import (
 	"github.com/hetznercloud/hcloud-go/hcloud/schema"
 )
 
+// CertificateType is the type of available certificate types.
+type CertificateType string
+
+// Available certificate types.
+const (
+	CertificateTypeUploaded CertificateType = "uploaded"
+	CertificateTypeManaged  CertificateType = "managed"
+)
+
+// CertificateStatusType is defines the type for the various managed
+// certificate status.
+type CertificateStatusType string
+
+// Possible certificate status.
+const (
+	CertificateStatusTypePending CertificateStatusType = "pending"
+	CertificateStatusTypeFailed  CertificateStatusType = "failed"
+
+	// only in issuance
+	CertificateStatusTypeCompleted CertificateStatusType = "completed"
+
+	// only in renewal
+	CertificateStatusTypeScheduled   CertificateStatusType = "scheduled"
+	CertificateStatusTypeUnavailable CertificateStatusType = "unavailable"
+)
+
+// CertificateUsedByRefType is the type of used by references for
+// certificates.
+type CertificateUsedByRefType string
+
+// Possible users of certificates.
+const (
+	CertificateUsedByRefTypeLoadBalancer CertificateUsedByRefType = "load_balancer"
+)
+
+// CertificateUsedByRef points to a resource that uses this certificate.
+type CertificateUsedByRef struct {
+	ID   int
+	Type CertificateUsedByRefType
+}
+
+// CertificateStatus indicates the status of a managed certificate.
+type CertificateStatus struct {
+	Issuance CertificateStatusType
+	Renewal  CertificateStatusType
+	Error    *Error
+}
+
+// IsFailed returns true if either the Issuance or the Renewal of a certificate
+// failed. In this case the FailureReason field details the nature of the
+// failure.
+func (st *CertificateStatus) IsFailed() bool {
+	return st.Issuance == CertificateStatusTypeFailed || st.Renewal == CertificateStatusTypeFailed
+}
+
 // Certificate represents an certificate in the Hetzner Cloud.
 type Certificate struct {
 	ID             int
 	Name           string
 	Labels         map[string]string
+	Type           CertificateType
 	Certificate    string
 	Created        time.Time
 	NotValidBefore time.Time
 	NotValidAfter  time.Time
 	DomainNames    []string
 	Fingerprint    string
+	Status         *CertificateStatus
+	UsedBy         []CertificateUsedByRef
+}
+
+// CertificateCreateResult is the result of creating a certificate.
+type CertificateCreateResult struct {
+	Certificate *Certificate
+	Action      *Action
 }
 
 // CertificateClient is a client for the Certificates API.
@@ -153,9 +217,11 @@ func (c *CertificateClient) AllWithOpts(ctx context.Context, opts CertificateLis
 // CertificateCreateOpts specifies options for creating a new Certificate.
 type CertificateCreateOpts struct {
 	Name        string
+	Type        CertificateType
 	Certificate string
 	PrivateKey  string
 	Labels      map[string]string
+	DomainNames []string
 }
 
 // Validate checks if options are valid.
@@ -163,6 +229,24 @@ func (o CertificateCreateOpts) Validate() error {
 	if o.Name == "" {
 		return errors.New("missing name")
 	}
+	switch o.Type {
+	case "", CertificateTypeUploaded:
+		return o.validateUploaded()
+	case CertificateTypeManaged:
+		return o.validateManaged()
+	default:
+		return fmt.Errorf("invalid type: %s", o.Type)
+	}
+}
+
+func (o CertificateCreateOpts) validateManaged() error {
+	if len(o.DomainNames) == 0 {
+		return errors.New("no domain names")
+	}
+	return nil
+}
+
+func (o CertificateCreateOpts) validateUploaded() error {
 	if o.Certificate == "" {
 		return errors.New("missing certificate")
 	}
@@ -172,34 +256,71 @@ func (o CertificateCreateOpts) Validate() error {
 	return nil
 }
 
-// Create creates a new certificate.
+// Create creates a new certificate uploaded certificate.
+//
+// Create returns an error for certificates of any other type. Use
+// CreateCertificate to create such certificates.
 func (c *CertificateClient) Create(ctx context.Context, opts CertificateCreateOpts) (*Certificate, *Response, error) {
+	if !(opts.Type == "" || opts.Type == CertificateTypeUploaded) {
+		return nil, nil, fmt.Errorf("invalid certificate type: %s", opts.Type)
+	}
+	result, resp, err := c.CreateCertificate(ctx, opts)
+	if err != nil {
+		return nil, resp, err
+	}
+	return result.Certificate, resp, nil
+}
+
+// CreateCertificate creates a new certificate of any type.
+func (c *CertificateClient) CreateCertificate(
+	ctx context.Context, opts CertificateCreateOpts,
+) (CertificateCreateResult, *Response, error) {
+	var (
+		action  *Action
+		reqBody schema.CertificateCreateRequest
+	)
+
 	if err := opts.Validate(); err != nil {
-		return nil, nil, err
+		return CertificateCreateResult{}, nil, err
 	}
-	reqBody := schema.CertificateCreateRequest{
-		Name:        opts.Name,
-		Certificate: opts.Certificate,
-		PrivateKey:  opts.PrivateKey,
+
+	reqBody.Name = opts.Name
+
+	switch opts.Type {
+	case "", CertificateTypeUploaded:
+		reqBody.Type = string(CertificateTypeUploaded)
+		reqBody.Certificate = opts.Certificate
+		reqBody.PrivateKey = opts.PrivateKey
+	case CertificateTypeManaged:
+		reqBody.Type = string(CertificateTypeManaged)
+		reqBody.DomainNames = opts.DomainNames
+	default:
+		return CertificateCreateResult{}, nil, fmt.Errorf("invalid certificate type: %v", opts.Type)
 	}
+
 	if opts.Labels != nil {
 		reqBody.Labels = &opts.Labels
 	}
 	reqBodyData, err := json.Marshal(reqBody)
 	if err != nil {
-		return nil, nil, err
+		return CertificateCreateResult{}, nil, err
 	}
 	req, err := c.client.NewRequest(ctx, "POST", "/certificates", bytes.NewReader(reqBodyData))
 	if err != nil {
-		return nil, nil, err
+		return CertificateCreateResult{}, nil, err
 	}
 
 	respBody := schema.CertificateCreateResponse{}
 	resp, err := c.client.Do(req, &respBody)
 	if err != nil {
-		return nil, resp, err
+		return CertificateCreateResult{}, resp, err
 	}
-	return CertificateFromSchema(respBody.Certificate), resp, nil
+	cert := CertificateFromSchema(respBody.Certificate)
+	if respBody.Action != nil {
+		action = ActionFromSchema(*respBody.Action)
+	}
+
+	return CertificateCreateResult{Certificate: cert, Action: action}, resp, nil
 }
 
 // CertificateUpdateOpts specifies options for updating a Certificate.
@@ -244,3 +365,19 @@ func (c *CertificateClient) Delete(ctx context.Context, certificate *Certificate
 	}
 	return c.client.Do(req, nil)
 }
+
+// RetryIssuance retries the issuance of a failed managed certificate.
+func (c *CertificateClient) RetryIssuance(ctx context.Context, certificate *Certificate) (*Action, *Response, error) {
+	var respBody schema.CertificateIssuanceRetryResponse
+
+	req, err := c.client.NewRequest(ctx, "POST", fmt.Sprintf("/certificates/%d/actions/retry", certificate.ID), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, nil, err
+	}
+	action := ActionFromSchema(respBody.Action)
+	return action, resp, nil
+}
diff --git a/hcloud/error.go b/hcloud/error.go
index 5066d7e..d54de70 100644
--- a/hcloud/error.go
+++ b/hcloud/error.go
@@ -58,6 +58,15 @@ const (
 	ErrorCodeResourceInUse           ErrorCode = "resource_in_use"           // Firewall must not be in use to be deleted
 	ErrorCodeServerAlreadyAdded      ErrorCode = "server_already_added"      // Server added more than one time to resource
 
+	// Certificate related error codes
+	ErrorCodeCAARecordDoesNotAllowCA                        ErrorCode = "caa_record_does_not_allow_ca"                          // CAA record does not allow certificate authority
+	ErrorCodeCADNSValidationFailed                          ErrorCode = "ca_dns_validation_failed"                              // Certificate Authority: DNS validation failed
+	ErrorCodeCATooManyAuthorizationsFailedRecently          ErrorCode = "ca_too_many_authorizations_failed_recently"            // Certificate Authority: Too many authorizations failed recently
+	ErrorCodeCATooManyCertificatedIssuedForRegisteredDomain ErrorCode = "ca_too_many_certificates_issued_for_registered_domain" // Certificate Authority: Too many certificates issued for registered domain
+	ErrorCodeCATooManyDuplicateCertificates                 ErrorCode = "ca_too_many_duplicate_certificates"                    // Certificate Authority: Too many duplicate certificates
+	ErrorCodeCloudNotVerifyDomainDelegatedToZone            ErrorCode = "could_not_verify_domain_delegated_to_zone"             // Could not verify domain delegated to zone
+	ErrorCodeDNSZoneNotFound                                ErrorCode = "dns_zone_not_found"                                    // DNS zone not found
+
 	// Deprecated error codes
 	// The actual value of this error code is limit_reached. The new error code
 	// rate_limit_exceeded for ratelimiting was introduced before Hetzner Cloud
diff --git a/hcloud/resource.go b/hcloud/resource.go
new file mode 100644
index 0000000..8a734df
--- /dev/null
+++ b/hcloud/resource.go
@@ -0,0 +1,7 @@
+package hcloud
+
+// Resource defines the schema of a resource.
+type Resource struct {
+	ID   int
+	Type string
+}
diff --git a/hcloud/schema.go b/hcloud/schema.go
index 363898a..2ab9e00 100644
--- a/hcloud/schema.go
+++ b/hcloud/schema.go
@@ -555,6 +555,7 @@ func CertificateFromSchema(s schema.Certificate) *Certificate {
 	c := &Certificate{
 		ID:             s.ID,
 		Name:           s.Name,
+		Type:           CertificateType(s.Type),
 		Certificate:    s.Certificate,
 		Created:        s.Created,
 		NotValidBefore: s.NotValidBefore,
@@ -562,12 +563,26 @@ func CertificateFromSchema(s schema.Certificate) *Certificate {
 		DomainNames:    s.DomainNames,
 		Fingerprint:    s.Fingerprint,
 	}
+	if s.Status != nil {
+		c.Status = &CertificateStatus{
+			Issuance: CertificateStatusType(s.Status.Issuance),
+			Renewal:  CertificateStatusType(s.Status.Renewal),
+		}
+		if s.Status.Error != nil {
+			certErr := ErrorFromSchema(*s.Status.Error)
+			c.Status.Error = &certErr
+		}
+	}
 	if len(s.Labels) > 0 {
-		c.Labels = make(map[string]string)
+		c.Labels = s.Labels
 	}
-	for key, value := range s.Labels {
-		c.Labels[key] = value
+	if len(s.UsedBy) > 0 {
+		c.UsedBy = make([]CertificateUsedByRef, len(s.UsedBy))
+		for i, ref := range s.UsedBy {
+			c.UsedBy[i] = CertificateUsedByRef{ID: ref.ID, Type: CertificateUsedByRefType(ref.Type)}
+		}
 	}
+
 	return c
 }
 
@@ -804,8 +819,10 @@ func loadBalancerCreateOptsToSchema(opts LoadBalancerCreateOpts) schema.LoadBala
 				StickySessions: service.HTTP.StickySessions,
 				CookieName:     service.HTTP.CookieName,
 			}
-			if sec := service.HTTP.CookieLifetime.Seconds(); sec != 0 {
-				schemaService.HTTP.CookieLifetime = Int(int(sec))
+			if service.HTTP.CookieLifetime != nil {
+				if sec := service.HTTP.CookieLifetime.Seconds(); sec != 0 {
+					schemaService.HTTP.CookieLifetime = Int(int(sec))
+				}
 			}
 			if service.HTTP.Certificates != nil {
 				certificates := []int{}
diff --git a/hcloud/schema/certificate.go b/hcloud/schema/certificate.go
index 251add4..a81b807 100644
--- a/hcloud/schema/certificate.go
+++ b/hcloud/schema/certificate.go
@@ -2,17 +2,32 @@ package schema
 
 import "time"
 
+// CertificateUsedByRef defines the schema of a resource using a certificate.
+type CertificateUsedByRef struct {
+	ID   int    `json:"id"`
+	Type string `json:"type"`
+}
+
+type CertificateStatusRef struct {
+	Issuance string `json:"issuance"`
+	Renewal  string `json:"renewal"`
+	Error    *Error `json:"error,omitempty"`
+}
+
 // Certificate defines the schema of an certificate.
 type Certificate struct {
-	ID             int               `json:"id"`
-	Name           string            `json:"name"`
-	Labels         map[string]string `json:"labels"`
-	Certificate    string            `json:"certificate"`
-	Created        time.Time         `json:"created"`
-	NotValidBefore time.Time         `json:"not_valid_before"`
-	NotValidAfter  time.Time         `json:"not_valid_after"`
-	DomainNames    []string          `json:"domain_names"`
-	Fingerprint    string            `json:"fingerprint"`
+	ID             int                    `json:"id"`
+	Name           string                 `json:"name"`
+	Labels         map[string]string      `json:"labels"`
+	Type           string                 `json:"type"`
+	Certificate    string                 `json:"certificate"`
+	Created        time.Time              `json:"created"`
+	NotValidBefore time.Time              `json:"not_valid_before"`
+	NotValidAfter  time.Time              `json:"not_valid_after"`
+	DomainNames    []string               `json:"domain_names"`
+	Fingerprint    string                 `json:"fingerprint"`
+	Status         *CertificateStatusRef  `json:"status"`
+	UsedBy         []CertificateUsedByRef `json:"used_by"`
 }
 
 // CertificateListResponse defines the schema of the response when
@@ -30,14 +45,17 @@ type CertificateGetResponse struct {
 // CertificateCreateRequest defines the schema of the request to create a certificate.
 type CertificateCreateRequest struct {
 	Name        string             `json:"name"`
-	Certificate string             `json:"certificate"`
-	PrivateKey  string             `json:"private_key"`
+	Type        string             `json:"type"`
+	DomainNames []string           `json:"domain_names,omitempty"`
+	Certificate string             `json:"certificate,omitempty"`
+	PrivateKey  string             `json:"private_key,omitempty"`
 	Labels      *map[string]string `json:"labels,omitempty"`
 }
 
 // CertificateCreateResponse defines the schema of the response when creating a certificate.
 type CertificateCreateResponse struct {
 	Certificate Certificate `json:"certificate"`
+	Action      *Action     `json:"action"`
 }
 
 // CertificateUpdateRequest defines the schema of the request to update a certificate.
@@ -50,3 +68,9 @@ type CertificateUpdateRequest struct {
 type CertificateUpdateResponse struct {
 	Certificate Certificate `json:"certificate"`
 }
+
+// CertificateIssuanceRetryResponse defines the schema for the response of the
+// retry issuance endpoint.
+type CertificateIssuanceRetryResponse struct {
+	Action Action `json:"action"`
+}


Test patch:
diff --git a/hcloud/certificate_test.go b/hcloud/certificate_test.go
index 273af07..055351a 100644
--- a/hcloud/certificate_test.go
+++ b/hcloud/certificate_test.go
@@ -7,63 +7,129 @@ import (
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/hetznercloud/hcloud-go/hcloud/schema"
 )
 
-func TestCertificateCreateOptsValidate(t *testing.T) {
-	testCases := map[string]struct {
-		Opts  CertificateCreateOpts
-		Valid bool
+func TestCertificateCreateOptsValidate_Uploaded(t *testing.T) {
+	tests := []struct {
+		name   string
+		opts   CertificateCreateOpts
+		errMsg string
 	}{
-		"empty": {
-			Opts:  CertificateCreateOpts{},
-			Valid: false,
-		},
-		"all set": {
-			Opts: CertificateCreateOpts{
-				Name:        "name",
+		{
+			name: "missing name",
+			opts: CertificateCreateOpts{
 				Certificate: "cert",
 				PrivateKey:  "key",
 				Labels:      map[string]string{},
 			},
-			Valid: true,
+			errMsg: "missing name",
+		},
+		{
+			name: "no certificate",
+			opts: CertificateCreateOpts{
+				Name:       "name",
+				PrivateKey: "key",
+				Labels:     map[string]string{},
+			},
+			errMsg: "missing certificate",
 		},
-		"no name": {
-			Opts: CertificateCreateOpts{
+		{
+			name: "no private key",
+			opts: CertificateCreateOpts{
+				Name:        "name",
 				Certificate: "cert",
-				PrivateKey:  "key",
 				Labels:      map[string]string{},
 			},
-			Valid: false,
+			errMsg: "missing private key",
 		},
-		"no certificate": {
-			Opts: CertificateCreateOpts{
-				Name:       "name",
-				PrivateKey: "key",
-				Labels:     map[string]string{},
+		{
+			name: "valid without type",
+			opts: CertificateCreateOpts{
+				Name:        "name",
+				Certificate: "cert",
+				PrivateKey:  "key",
+				Labels:      map[string]string{},
 			},
-			Valid: false,
 		},
-		"no private key": {
-			Opts: CertificateCreateOpts{
+		{
+			name: "valid with type",
+			opts: CertificateCreateOpts{
 				Name:        "name",
+				Type:        "uploaded",
 				Certificate: "cert",
+				PrivateKey:  "key",
 				Labels:      map[string]string{},
 			},
-			Valid: false,
 		},
 	}
-	for name, testCase := range testCases {
-		t.Run(name, func(t *testing.T) {
-			err := testCase.Opts.Validate()
-			if err == nil && !testCase.Valid || err != nil && testCase.Valid {
-				t.FailNow()
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.opts.Validate()
+			if tt.errMsg != "" {
+				assert.EqualError(t, err, tt.errMsg)
+				return
+			}
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestCertificateCreateOptsValidate_Managed(t *testing.T) {
+	tests := []struct {
+		name   string
+		opts   CertificateCreateOpts
+		errMsg string
+	}{
+		{
+			name: "missing name",
+			opts: CertificateCreateOpts{
+				Type:        "managed",
+				DomainNames: []string{"*.example.com", "example.com"},
+			},
+			errMsg: "missing name",
+		},
+		{
+			name: "missing domains",
+			opts: CertificateCreateOpts{
+				Name: "I have no domains",
+				Type: "managed",
+			},
+			errMsg: "no domain names",
+		},
+		{
+			name: "valid certificate",
+			opts: CertificateCreateOpts{
+				Name:        "valid",
+				Type:        "managed",
+				DomainNames: []string{"*.example.com", "example.com"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.opts.Validate()
+			if tt.errMsg != "" {
+				assert.EqualError(t, err, tt.errMsg)
+				return
 			}
+			assert.NoError(t, err)
 		})
 	}
 }
 
+func TestCertificateCreateOptsValidate_InvalidType(t *testing.T) {
+	opts := CertificateCreateOpts{Name: "invalid type", Type: "invalid"}
+	err := opts.Validate()
+	assert.EqualError(t, err, "invalid type: invalid")
+}
+
 func TestCertificateClientGetByID(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
@@ -212,7 +278,7 @@ func TestCertificateClientGetByNameEmpty(t *testing.T) {
 	}
 }
 
-func TestCertificateCreate(t *testing.T) {
+func TestCertificateCreate_Uploaded(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
 
@@ -223,6 +289,7 @@ func TestCertificateCreate(t *testing.T) {
 		}
 		expectedReqBody := schema.CertificateCreateRequest{
 			Name:        "my-cert",
+			Type:        "uploaded",
 			Certificate: "-----BEGIN CERTIFICATE-----\n...",
 			PrivateKey:  "-----BEGIN PRIVATE KEY-----\n...",
 			Labels: func() *map[string]string {
@@ -253,6 +320,49 @@ func TestCertificateCreate(t *testing.T) {
 	}
 }
 
+func TestCertificateCreate_Managed(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/certificates", func(w http.ResponseWriter, r *http.Request) {
+		var reqBody schema.CertificateCreateRequest
+		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+			t.Fatal(err)
+		}
+		expectedReqBody := schema.CertificateCreateRequest{
+			Name:        "my-cert",
+			Type:        "managed",
+			DomainNames: []string{"*.example.com", "example.com"},
+			Labels: func() *map[string]string {
+				labels := map[string]string{"key": "value"}
+				return &labels
+			}(),
+		}
+		if !cmp.Equal(expectedReqBody, reqBody) {
+			t.Log(cmp.Diff(expectedReqBody, reqBody))
+			t.Error("unexpected request body")
+		}
+		json.NewEncoder(w).Encode(schema.CertificateCreateResponse{
+			Certificate: schema.Certificate{ID: 1},
+			Action:      &schema.Action{ID: 14},
+		})
+	})
+
+	ctx := context.Background()
+	opts := CertificateCreateOpts{
+		Name:        "my-cert",
+		Type:        "managed",
+		DomainNames: []string{"*.example.com", "example.com"},
+		Labels:      map[string]string{"key": "value"},
+	}
+
+	result, resp, err := env.Client.Certificate.CreateCertificate(ctx, opts)
+	assert.NoError(t, err)
+	assert.NotNil(t, resp, "no response returned")
+	assert.NotNil(t, result.Certificate, "no certificate returned")
+	assert.NotNil(t, result.Action, "no action returned")
+}
+
 func TestCertificateCreateValidation(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
@@ -329,3 +439,22 @@ func TestCertificateClientUpdate(t *testing.T) {
 		t.Errorf("unexpected certficate ID: %v", updatedCertificate.ID)
 	}
 }
+
+func TestCertificateClient_RetryIssuance(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/certificates/1/actions/retry", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "POST", r.Method)
+
+		resp := schema.CertificateIssuanceRetryResponse{
+			Action: schema.Action{ID: 1},
+		}
+		err := json.NewEncoder(w).Encode(resp)
+		assert.NoError(t, err)
+	})
+
+	action, _, err := env.Client.Certificate.RetryIssuance(context.Background(), &Certificate{ID: 1})
+	assert.NoError(t, err)
+	assert.Equal(t, &Action{ID: 1, Resources: []*ActionResource{}}, action)
+}
diff --git a/hcloud/load_balancer_test.go b/hcloud/load_balancer_test.go
index 5d54b0e..e414831 100644
--- a/hcloud/load_balancer_test.go
+++ b/hcloud/load_balancer_test.go
@@ -1036,14 +1036,14 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 					LoadBalancerMetricRequestsPerSecond,
 					LoadBalancerMetricBandwidth,
 				},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			respFn: func() schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{
@@ -1080,8 +1080,8 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 				return resp
 			},
 			expected: LoadBalancerMetrics{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 				TimeSeries: map[string][]LoadBalancerMetricsValue{
 					"open_connections": {
 						{Timestamp: 1435781470.622, Value: "42"},
@@ -1110,8 +1110,8 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 			name: "missing metrics types",
 			lb:   &LoadBalancer{ID: 3},
 			opts: LoadBalancerGetMetricsOpts{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "add query params: no metric types specified",
 		},
@@ -1120,7 +1120,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 			lb:   &LoadBalancer{ID: 4},
 			opts: LoadBalancerGetMetricsOpts{
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "add query params: no start time specified",
 		},
@@ -1129,7 +1129,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 			lb:   &LoadBalancer{ID: 5},
 			opts: LoadBalancerGetMetricsOpts{
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
 			},
 			expectedErr: "add query params: no end time specified",
 		},
@@ -1138,8 +1138,8 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 			lb:   &LoadBalancer{ID: 6},
 			opts: LoadBalancerGetMetricsOpts{
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			respStatus:  http.StatusInternalServerError,
 			expectedErr: "get metrics: hcloud: server responded with status code 500",
@@ -1148,8 +1148,8 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 			name: "no load balancer passed",
 			opts: LoadBalancerGetMetricsOpts{
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "illegal argument: load balancer is nil",
 		},
diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go
index 8c8a2c9..f767920 100644
--- a/hcloud/schema_test.go
+++ b/hcloud/schema_test.go
@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/hetznercloud/hcloud-go/hcloud/schema"
 )
@@ -1671,67 +1672,110 @@ func TestLoadBalancerTargetFromSchema(t *testing.T) {
 }
 
 func TestCertificateFromSchema(t *testing.T) {
-	data := []byte(`{
-		"id": 897,
-		"name": "my website cert",
-		"labels": {},
-		"certificate": "-----BEGIN CERTIFICATE-----\n...",
-		"created": "2016-01-30T23:50:00+00:00",
-		"not_valid_before": "2016-01-30T23:51:00+00:00",
-		"not_valid_after": "2016-01-30T23:55:00+00:00",
-		"domain_names": [
-			"example.com",
-			"webmail.example.com",
-			"www.example.com"
-		],
-		"fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f",
-		"used_by": [
-			{
-			    "id": 42,
-			    "type": "server"
-			}
-		]
-	}
-`)
-	var s schema.Certificate
-	if err := json.Unmarshal(data, &s); err != nil {
-		t.Fatal(err)
+	tests := []struct {
+		name     string
+		data     string
+		expected Certificate
+	}{
+		{
+			name: "uploaded certificate",
+			data: `{
+				"id": 897,
+				"name": "my website cert",
+				"labels": {},
+				"type": "uploaded",
+				"certificate": "-----BEGIN CERTIFICATE-----\n...",
+				"created": "2016-01-30T23:50:00+00:00",
+				"not_valid_before": "2016-01-30T23:51:00+00:00",
+				"not_valid_after": "2016-01-30T23:55:00+00:00",
+				"domain_names": [
+					"example.com",
+					"webmail.example.com",
+					"www.example.com"
+				],
+				"fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f",
+				"used_by": [
+					{"id": 42, "type": "loadbalancer"}
+				]
+			}`,
+			expected: Certificate{
+				ID:             897,
+				Name:           "my website cert",
+				Type:           "uploaded",
+				Certificate:    "-----BEGIN CERTIFICATE-----\n...",
+				Created:        mustParseTime(t, apiTimestampFormat, "2016-01-30T23:50:00+00:00"),
+				NotValidBefore: mustParseTime(t, apiTimestampFormat, "2016-01-30T23:51:00+00:00"),
+				NotValidAfter:  mustParseTime(t, apiTimestampFormat, "2016-01-30T23:55:00+00:00"),
+				DomainNames:    []string{"example.com", "webmail.example.com", "www.example.com"},
+				Fingerprint:    "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f",
+				UsedBy: []CertificateUsedByRef{
+					{ID: 42, Type: "loadbalancer"},
+				},
+			},
+		},
+		{
+			name: "managed certificate",
+			data: `{
+				"id": 898,
+				"name": "managed certificate",
+				"labels": {},
+				"type": "managed",
+				"certificate": "-----BEGIN CERTIFICATE-----\n...",
+				"created": "2016-01-30T23:50:00+00:00",
+				"not_valid_before": "2016-01-30T23:51:00+00:00",
+				"not_valid_after": "2016-01-30T23:55:00+00:00",
+				"domain_names": [
+					"example.com",
+					"webmail.example.com",
+					"www.example.com"
+				],
+				"fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f",
+				"status": {
+					"issuance": "completed",
+					"renewal": "failed",
+					"error": {
+						"code": "dns_zone_not_found",
+						"message": "DNS zone not found"
+					}
+				},
+				"used_by": [
+					{"id": 42, "type": "loadbalancer"}
+				]
+			}`,
+			expected: Certificate{
+				ID:             898,
+				Name:           "managed certificate",
+				Type:           "managed",
+				Certificate:    "-----BEGIN CERTIFICATE-----\n...",
+				Created:        mustParseTime(t, apiTimestampFormat, "2016-01-30T23:50:00+00:00"),
+				NotValidBefore: mustParseTime(t, apiTimestampFormat, "2016-01-30T23:51:00+00:00"),
+				NotValidAfter:  mustParseTime(t, apiTimestampFormat, "2016-01-30T23:55:00+00:00"),
+				DomainNames:    []string{"example.com", "webmail.example.com", "www.example.com"},
+				Fingerprint:    "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f",
+				Status: &CertificateStatus{
+					Issuance: CertificateStatusTypeCompleted,
+					Renewal:  CertificateStatusTypeFailed,
+					Error: &Error{
+						Code:    "dns_zone_not_found",
+						Message: "DNS zone not found",
+					},
+				},
+				UsedBy: []CertificateUsedByRef{
+					{ID: 42, Type: "loadbalancer"},
+				},
+			},
+		},
 	}
-	certificate := CertificateFromSchema(s)
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			var s schema.Certificate
 
-	if certificate.ID != 897 {
-		t.Errorf("unexpected ID: %v", certificate.ID)
-	}
-	if certificate.Name != "my website cert" {
-		t.Errorf("unexpected Name: %v", certificate.Name)
-	}
-	if certificate.Certificate != "-----BEGIN CERTIFICATE-----\n..." {
-		t.Errorf("unexpected Certificate: %v", certificate.Certificate)
-	}
-	if !certificate.Created.Equal(time.Date(2016, 01, 30, 23, 50, 00, 0, time.UTC)) {
-		t.Errorf("unexpected created date: %v", certificate.Created)
-	}
-	if !certificate.NotValidBefore.Equal(time.Date(2016, 01, 30, 23, 51, 00, 0, time.UTC)) {
-		t.Errorf("unexpected NotValidBefore: %v", certificate.NotValidBefore)
-	}
-	if !certificate.NotValidAfter.Equal(time.Date(2016, 01, 30, 23, 55, 00, 0, time.UTC)) {
-		t.Errorf("unexpected NotValidAfter: %v", certificate.NotValidAfter)
-	}
-	if len(certificate.DomainNames) != 3 {
-		t.Errorf("unexpected DomainNames length: %v", len(certificate.DomainNames))
-	} else {
-		if certificate.DomainNames[0] != "example.com" {
-			t.Errorf("unexpected DomainNames[0]: %v", certificate.DomainNames[0])
-		}
-		if certificate.DomainNames[1] != "webmail.example.com" {
-			t.Errorf("unexpected DomainNames[1]: %v", certificate.DomainNames[1])
-		}
-		if certificate.DomainNames[2] != "www.example.com" {
-			t.Errorf("unexpected DomainNames[2]: %v", certificate.DomainNames[2])
-		}
-	}
-	if certificate.Fingerprint != "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" {
-		t.Errorf("unexpected Fingerprint: %v", certificate.Fingerprint)
+			err := json.Unmarshal([]byte(tt.data), &s)
+			assert.NoError(t, err)
+			actual := CertificateFromSchema(s)
+			assert.Equal(t, &tt.expected, actual)
+		})
 	}
 }
 
@@ -2323,8 +2367,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{"some value"},
@@ -2340,8 +2384,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -2359,8 +2403,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -2378,8 +2422,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -2397,8 +2441,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -2435,8 +2479,8 @@ func TestServerMetricsFromSchema(t *testing.T) {
 				return &resp
 			},
 			expected: &ServerMetrics{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 				TimeSeries: map[string][]ServerMetricsValue{
 					"cpu": {
 						{Timestamp: 1435781470.622, Value: "42"},
@@ -2493,8 +2537,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{"some value"},
@@ -2510,8 +2554,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{
@@ -2529,8 +2573,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{
@@ -2548,8 +2592,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{
@@ -2567,8 +2611,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 			respFn: func() *schema.LoadBalancerGetMetricsResponse {
 				var resp schema.LoadBalancerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
 					"open_connections": {
 						Values: []interface{}{
@@ -2605,8 +2649,8 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) {
 				return &resp
 			},
 			expected: &LoadBalancerMetrics{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 				TimeSeries: map[string][]LoadBalancerMetricsValue{
 					"open_connections": {
 						{Timestamp: 1435781470.622, Value: "42"},
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index 5d81432..06b4204 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -1810,14 +1810,14 @@ func TestServerGetMetrics(t *testing.T) {
 			server: &Server{ID: 1},
 			opts: ServerGetMetricsOpts{
 				Types: []ServerMetricType{ServerMetricCPU},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			respFn: func() schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -1830,8 +1830,8 @@ func TestServerGetMetrics(t *testing.T) {
 				return resp
 			},
 			expected: ServerMetrics{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 				TimeSeries: map[string][]ServerMetricsValue{
 					"cpu": {
 						{Timestamp: 1435781470.622, Value: "42"},
@@ -1849,14 +1849,14 @@ func TestServerGetMetrics(t *testing.T) {
 					ServerMetricDisk,
 					ServerMetricNetwork,
 				},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			respFn: func() schema.ServerGetMetricsResponse {
 				var resp schema.ServerGetMetricsResponse
 
-				resp.Metrics.Start = mustParseTime(t, "2017-01-01T00:00:00Z")
-				resp.Metrics.End = mustParseTime(t, "2017-01-01T23:00:00Z")
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
 				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
 					"cpu": {
 						Values: []interface{}{
@@ -1893,8 +1893,8 @@ func TestServerGetMetrics(t *testing.T) {
 				return resp
 			},
 			expected: ServerMetrics{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 				TimeSeries: map[string][]ServerMetricsValue{
 					"cpu": {
 						{Timestamp: 1435781470.622, Value: "42"},
@@ -1923,8 +1923,8 @@ func TestServerGetMetrics(t *testing.T) {
 			name:   "missing metrics types",
 			server: &Server{ID: 3},
 			opts: ServerGetMetricsOpts{
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "add query params: no metric types specified",
 		},
@@ -1933,7 +1933,7 @@ func TestServerGetMetrics(t *testing.T) {
 			server: &Server{ID: 4},
 			opts: ServerGetMetricsOpts{
 				Types: []ServerMetricType{ServerMetricCPU},
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "add query params: no start time specified",
 		},
@@ -1942,7 +1942,7 @@ func TestServerGetMetrics(t *testing.T) {
 			server: &Server{ID: 5},
 			opts: ServerGetMetricsOpts{
 				Types: []ServerMetricType{ServerMetricCPU},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
 			},
 			expectedErr: "add query params: no end time specified",
 		},
@@ -1951,8 +1951,8 @@ func TestServerGetMetrics(t *testing.T) {
 			server: &Server{ID: 6},
 			opts: ServerGetMetricsOpts{
 				Types: []ServerMetricType{ServerMetricCPU},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			respStatus:  http.StatusInternalServerError,
 			expectedErr: "get metrics: hcloud: server responded with status code 500",
@@ -1961,8 +1961,8 @@ func TestServerGetMetrics(t *testing.T) {
 			name: "no server passed",
 			opts: ServerGetMetricsOpts{
 				Types: []ServerMetricType{ServerMetricCPU},
-				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
-				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
 			},
 			expectedErr: "illegal argument: server is nil",
 		},
@@ -2062,13 +2062,3 @@ func serverMetricsOptsFromURL(t *testing.T, u *url.URL) ServerGetMetricsOpts {
 
 	return opts
 }
-
-func mustParseTime(t *testing.T, value string) time.Time {
-	t.Helper()
-
-	ts, err := time.Parse(time.RFC3339, value)
-	if err != nil {
-		t.Fatalf("parse time: layout %v: value %v", time.RFC3339, value)
-	}
-	return ts
-}
diff --git a/hcloud/testing.go b/hcloud/testing.go
new file mode 100644
index 0000000..a3cfb2f
--- /dev/null
+++ b/hcloud/testing.go
@@ -0,0 +1,18 @@
+package hcloud
+
+import (
+	"testing"
+	"time"
+)
+
+const apiTimestampFormat = "2006-01-02T15:04:05-07:00"
+
+func mustParseTime(t *testing.T, layout, value string) time.Time {
+	t.Helper()
+
+	ts, err := time.Parse(layout, value)
+	if err != nil {
+		t.Fatalf("parse time: layout %v: value %v: %v", layout, value, err)
+	}
+	return ts
+}