Repository: smallstep/crypto
Pull request #62 (2022-08-23 13:00:51)

Add `ValidateTemplate` function


Patch:
diff --git a/go.mod b/go.mod
index 15ff905..e4b0c4c 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/googleapis/gax-go/v2 v2.4.0
 	github.com/pkg/errors v0.9.1
 	github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262
+	github.com/stretchr/testify v1.7.1
 	go.step.sm/cli-utils v0.7.3
 	golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
 	golang.org/x/net v0.0.0-20220607020251-c690dde0001d
@@ -40,6 +41,7 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1 // indirect
 	github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dimchansky/utfbom v1.1.1 // indirect
 	github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -55,9 +57,9 @@ require (
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/shopspring/decimal v1.2.0 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
-	github.com/stretchr/testify v1.7.1 // indirect
 	github.com/thales-e-security/pool v0.0.2 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb // indirect
diff --git a/go.sum b/go.sum
index 23426d8..3a92078 100644
--- a/go.sum
+++ b/go.sum
@@ -251,8 +251,10 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
@@ -747,6 +749,7 @@ google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscL
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
diff --git a/internal/templates/funcmap.go b/internal/templates/funcmap.go
new file mode 100644
index 0000000..01b6866
--- /dev/null
+++ b/internal/templates/funcmap.go
@@ -0,0 +1,26 @@
+package templates
+
+import (
+	"errors"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+)
+
+// GetFuncMap returns the list of functions provided by sprig. It changes the
+// function "fail" to set the given string, this way we can report template
+// errors directly to the template without having the wrapper that text/template
+// adds.
+//
+// sprig "env" and "expandenv" functions are removed to avoid the leak of
+// information.
+func GetFuncMap(failMessage *string) template.FuncMap {
+	m := sprig.TxtFuncMap()
+	delete(m, "env")
+	delete(m, "expandenv")
+	m["fail"] = func(msg string) (string, error) {
+		*failMessage = msg
+		return "", errors.New(msg)
+	}
+	return m
+}
diff --git a/internal/templates/validate.go b/internal/templates/validate.go
new file mode 100644
index 0000000..f8b5e7b
--- /dev/null
+++ b/internal/templates/validate.go
@@ -0,0 +1,45 @@
+package templates
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"text/template"
+)
+
+// ValidateTemplate validates a text template results in valid JSON
+// when it's executed with empty template data. If template execution
+// results in invalid JSON, the template is invalid. When the template
+// is valid, it can be used safely. A valid template can still result
+// in invalid JSON when non-empty template data is provided.
+func ValidateTemplate(data []byte) error {
+	if len(data) == 0 {
+		return nil
+	}
+
+	// get the default supported functions
+	var failMessage string
+	funcMap := GetFuncMap(&failMessage)
+
+	// prepare the template with our template functions
+	_, err := template.New("template").Funcs(funcMap).Parse(string(data))
+	if err != nil {
+		return fmt.Errorf("error parsing template: %w", err)
+	}
+
+	return nil
+}
+
+// ValidateTemplateData validates that template data is
+// valid JSON.
+func ValidateTemplateData(data []byte) error {
+	if len(data) == 0 {
+		return nil
+	}
+
+	if ok := json.Valid(data); !ok {
+		return errors.New("error validating json template data")
+	}
+
+	return nil
+}
diff --git a/sshutil/options.go b/sshutil/options.go
index 4e1e3d6..1c27d80 100644
--- a/sshutil/options.go
+++ b/sshutil/options.go
@@ -6,29 +6,12 @@ import (
 	"os"
 	"text/template"
 
-	"github.com/Masterminds/sprig/v3"
 	"github.com/pkg/errors"
+
 	"go.step.sm/crypto/internal/step"
+	"go.step.sm/crypto/internal/templates"
 )
 
-// getFuncMap returns the list of functions provided by sprig. It changes the
-// function "fail" to set the given string, this way we can report template
-// errors directly to the template without having the wrapper that text/template
-// adds.
-//
-// sprig "env" and "expandenv" functions are removed to avoid the leak of
-// information.
-func getFuncMap(failMessage *string) template.FuncMap {
-	m := sprig.TxtFuncMap()
-	delete(m, "env")
-	delete(m, "expandenv")
-	m["fail"] = func(msg string) (string, error) {
-		*failMessage = msg
-		return "", errors.New(msg)
-	}
-	return m
-}
-
 // Options are the options that can be passed to NewCertificate.
 type Options struct {
 	CertBuffer *bytes.Buffer
@@ -51,7 +34,7 @@ type Option func(cr CertificateRequest, o *Options) error
 func WithTemplate(text string, data TemplateData) Option {
 	return func(cr CertificateRequest, o *Options) error {
 		terr := new(TemplateError)
-		funcMap := getFuncMap(&terr.Message)
+		funcMap := templates.GetFuncMap(&terr.Message)
 
 		tmpl, err := template.New("template").Funcs(funcMap).Parse(text)
 		if err != nil {
diff --git a/sshutil/templates.go b/sshutil/templates.go
index b6adc70..48d4871 100644
--- a/sshutil/templates.go
+++ b/sshutil/templates.go
@@ -1,5 +1,7 @@
 package sshutil
 
+import "go.step.sm/crypto/internal/templates"
+
 // Variables used to hold template data.
 const (
 	TypeKey               = "Type"
@@ -27,6 +29,17 @@ func (e *TemplateError) Error() string {
 	return e.Message
 }
 
+// ValidateTemplate validates a text template.
+func ValidateTemplate(text []byte) error {
+	return templates.ValidateTemplate(text)
+}
+
+// ValidateTemplateData validates that template data is
+// valid JSON.
+func ValidateTemplateData(data []byte) error {
+	return templates.ValidateTemplateData(data)
+}
+
 // TemplateData is an alias for map[string]interface{}. It represents the data
 // passed to the templates.
 type TemplateData map[string]interface{}
diff --git a/x509util/options.go b/x509util/options.go
index ffeb031..8b5f0d1 100644
--- a/x509util/options.go
+++ b/x509util/options.go
@@ -7,29 +7,12 @@ import (
 	"os"
 	"text/template"
 
-	"github.com/Masterminds/sprig/v3"
 	"github.com/pkg/errors"
+
 	"go.step.sm/crypto/internal/step"
+	"go.step.sm/crypto/internal/templates"
 )
 
-// getFuncMap returns the list of functions provided by sprig. It changes the
-// function "fail" to set the given string, this way we can report template
-// errors directly to the template without having the wrapper that text/template
-// adds.
-//
-// sprig "env" and "expandenv" functions are removed to avoid the leak of
-// information.
-func getFuncMap(failMessage *string) template.FuncMap {
-	m := sprig.TxtFuncMap()
-	delete(m, "env")
-	delete(m, "expandenv")
-	m["fail"] = func(msg string) (string, error) {
-		*failMessage = msg
-		return "", errors.New(msg)
-	}
-	return m
-}
-
 // Options are the options that can be passed to NewCertificate.
 type Options struct {
 	CertBuffer *bytes.Buffer
@@ -52,7 +35,7 @@ type Option func(cr *x509.CertificateRequest, o *Options) error
 func WithTemplate(text string, data TemplateData) Option {
 	return func(cr *x509.CertificateRequest, o *Options) error {
 		terr := new(TemplateError)
-		funcMap := getFuncMap(&terr.Message)
+		funcMap := templates.GetFuncMap(&terr.Message)
 
 		tmpl, err := template.New("template").Funcs(funcMap).Parse(text)
 		if err != nil {
diff --git a/x509util/templates.go b/x509util/templates.go
index 4845700..26ea248 100644
--- a/x509util/templates.go
+++ b/x509util/templates.go
@@ -2,6 +2,8 @@ package x509util
 
 import (
 	"crypto/x509"
+
+	"go.step.sm/crypto/internal/templates"
 )
 
 // Variables used to hold template data.
@@ -28,6 +30,17 @@ func (e *TemplateError) Error() string {
 	return e.Message
 }
 
+// ValidateTemplate validates a text template.
+func ValidateTemplate(text []byte) error {
+	return templates.ValidateTemplate(text)
+}
+
+// ValidateTemplateData validates that template data is
+// valid JSON.
+func ValidateTemplateData(data []byte) error {
+	return templates.ValidateTemplateData(data)
+}
+
 // TemplateData is an alias for map[string]interface{}. It represents the data
 // passed to the templates.
 type TemplateData map[string]interface{}


Test patch:
diff --git a/internal/templates/funcmap_test.go b/internal/templates/funcmap_test.go
new file mode 100644
index 0000000..155aed1
--- /dev/null
+++ b/internal/templates/funcmap_test.go
@@ -0,0 +1,22 @@
+package templates
+
+import (
+	"errors"
+	"testing"
+)
+
+func Test_GetFuncMap_fail(t *testing.T) {
+	var failMesage string
+	fns := GetFuncMap(&failMesage)
+	fail := fns["fail"].(func(s string) (string, error))
+	s, err := fail("the fail message")
+	if err == nil {
+		t.Errorf("fail() error = %v, wantErr %v", err, errors.New("the fail message"))
+	}
+	if s != "" {
+		t.Errorf("fail() = \"%s\", want \"the fail message\"", s)
+	}
+	if failMesage != "the fail message" {
+		t.Errorf("fail() message = \"%s\", want \"the fail message\"", failMesage)
+	}
+}
diff --git a/internal/templates/validate_test.go b/internal/templates/validate_test.go
new file mode 100644
index 0000000..da7c767
--- /dev/null
+++ b/internal/templates/validate_test.go
@@ -0,0 +1,253 @@
+package templates
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestValidateTemplate(t *testing.T) {
+	tests := []struct {
+		name string
+		data []byte
+		err  error
+	}{
+		{
+			name: "ok/default-leaf-template",
+			data: []byte(`{
+				"subject": {{ toJson .Subject }},
+				"sans": {{ toJson .SANs }},
+			{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
+				"keyUsage": ["keyEncipherment", "digitalSignature"],
+			{{- else }}
+				"keyUsage": ["digitalSignature"],
+			{{- end }}
+				"extKeyUsage": ["serverAuth", "clientAuth"]
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/default-x509-iid-template",
+			data: []byte(`{
+				"subject": {"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}},
+			{{- if .SANs }}
+				"sans": {{ toJson .SANs }},
+			{{- else }}
+				"dnsNames": {{ toJson .Insecure.CR.DNSNames }},
+				"emailAddresses": {{ toJson .Insecure.CR.EmailAddresses }},
+				"ipAddresses": {{ toJson .Insecure.CR.IPAddresses }},
+				"uris": {{ toJson .Insecure.CR.URIs }},
+			{{- end }}
+			{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
+				"keyUsage": ["keyEncipherment", "digitalSignature"],
+			{{- else }}
+				"keyUsage": ["digitalSignature"],
+			{{- end }}
+				"extKeyUsage": ["serverAuth", "clientAuth"]
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/default-x509-adobe",
+			data: []byte(`{
+				"test:": "default-x509-adobe",
+				"subject": {{ toJson .Token.email }},
+				"sans": {{ toJson .SANs }},
+			{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
+				"keyUsage": ["dataEncipherment", "digitalSignature", "keyAgreement"],
+			{{- else }}
+				{{ fail "Key type must be RSA. Try again with --kty=RSA" }}
+			{{- end }}
+				"extensions": [{"id": "1.2.840.113583.1.1.10", "value": "BQA="}]
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/range-subdomains-regex",
+			data: []byte(`{
+				{{ range .SANs }}
+					{{ if not (and (regexMatch ".*\\.smallstep\\.com" .Value) (eq .Type "dns")) }}
+						{{ fail "Not a *.smallstep.com host" }}
+					{{ end }}
+				{{ end }}
+				"subject": {{ toJson .Subject }},
+				"sans": {{ toJson .SANs }},
+				"keyUsage": ["digitalSignature", "keyEncipherment", "keyAgreement"],
+				"extKeyUsage": ["serverAuth"]
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/default-ssh-iid-template",
+			data: []byte(`{
+				"type": {{ toJson .Type }},
+				"keyId": {{ toJson .KeyID }},
+			{{- if .Insecure.CR.Principals }}
+				"principals": {{ toJson .Insecure.CR.Principals }},
+			{{- else }}
+				"principals": {{ toJson .Principals }},
+			{{- end }}
+				"extensions": {{ toJson .Extensions }}
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/ssh-cr-template",
+			data: []byte(`{
+				"type": {{ toJson .Insecure.CR.Type }},
+				"keyId": {{ toJson .Insecure.CR.KeyID }},
+				"principals": {{ toJson .Insecure.CR.Principals }}
+			{{- if eq .Insecure.CR.Type "user" }}
+				, "extensions": {
+					"permit-X11-forwarding":   "",
+					"permit-agent-forwarding": "",
+					"permit-port-forwarding":  "",
+					"permit-pty":              "",
+					"permit-user-rc":          ""
+				}
+			{{- end }}
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/ssh-github-token",
+			data: []byte(`{
+				"type": {{ toJson .Type }},
+				"keyId": {{ toJson .KeyID }},
+				"principals": {{ toJson .Principals }},
+				"criticalOptions": {{ toJson .CriticalOptions }},
+			{{ if .Token.ghu }}
+				"extensions": {
+				  "login@github.com": {{ toJson .Token.ghu }}
+				}
+			{{ else }}
+				"extensions": {{ toJson .Extensions }}
+			{{ end }}
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok/empty-template",
+			data: []byte(""),
+			err:  nil,
+		},
+		{
+			name: "ok/nil-template",
+			data: nil,
+			err:  nil,
+		},
+		{
+			name: "ok/template-with-nested-property",
+			data: []byte(`
+			{{ if not .Token.ghu.foo }}
+				{{ toJson "token has no GitHub username" }}
+			{{ end }}
+		  `),
+			err: nil,
+		},
+		{
+			name: "fail/template-parsing-unterminated-quoted-string",
+			data: []byte(`
+			{{ if not .Token.ghu }} 
+				{{ fail "token has no GitHub username }}
+			{{ end }}
+			`),
+			err: errors.New("error parsing template: template: template:3: unterminated quoted string"),
+		},
+		{
+			name: "fail/template-parsing-unknown-function",
+			data: []byte(`{
+				"subject": {{ unknownFunction .Subject }}
+			}`),
+			err: errors.New("error parsing template: template: template:2: function \"unknownFunction\" not defined"),
+		},
+		{
+			name: "fail/template-parsing-missing-closing-brace",
+			data: []byte(`{
+				"subject": {{ toJson .Subject }},
+				"sans": {{ toJson .SANs }
+			}`),
+			err: errors.New("error parsing template: template: template:3: unexpected \"}\" in operand"),
+		},
+		{
+			name: "ok/json-extraneous-trailing-brace",
+			data: []byte(`
+				{
+					"subject": {{ toJson .Subject }}},
+					"issuer": {{ toJson .Subject }}
+				}
+			`),
+			err: nil, // NOTE: ideally we would like to catch this, but without validating the JSON, this seems hard
+		},
+		{
+			name: "ok/json-missing-trailing-comma",
+			data: []byte(`{
+				"subject": {{ toJson .Subject }}
+				"sans": {{ toJson .SANs }}
+			}`),
+			err: nil, // NOTE: ideally we would like to catch this, but without validating the JSON, this seems hard
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := ValidateTemplate(tt.data)
+			if tt.err != nil {
+				assert.Error(t, err)
+				assert.EqualError(t, err, tt.err.Error())
+				return
+			}
+
+			assert.Nil(t, err)
+
+		})
+	}
+}
+
+func TestValidateTemplateData(t *testing.T) {
+	tests := []struct {
+		name string
+		data []byte
+		err  error
+	}{
+		{
+			name: "ok",
+			data: []byte(`{
+				"x": 1,
+				"y": 2, 
+				"z": {"a":null, "b":"c"}
+			}`),
+			err: nil,
+		},
+		{
+			name: "ok empty",
+			data: []byte(`{}`),
+			err:  nil,
+		},
+		{
+			name: "ok nil",
+			data: nil,
+			err:  nil,
+		},
+		{
+			name: "fail/missing-comma-trailing-comma",
+			data: []byte(`{
+				"x": 1
+				"y": 2
+			}`),
+			err: errors.New("error validating json template data"),
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := ValidateTemplateData(tt.data)
+			if tt.err != nil {
+				assert.Error(t, err)
+				assert.EqualError(t, err, tt.err.Error())
+				return
+			}
+
+			assert.Nil(t, err)
+		})
+	}
+}
diff --git a/sshutil/options_test.go b/sshutil/options_test.go
index 7b44403..7e90995 100644
--- a/sshutil/options_test.go
+++ b/sshutil/options_test.go
@@ -5,26 +5,8 @@ import (
 	"encoding/base64"
 	"reflect"
 	"testing"
-
-	"github.com/pkg/errors"
 )
 
-func Test_getFuncMap_fail(t *testing.T) {
-	var failMesage string
-	fns := getFuncMap(&failMesage)
-	fail := fns["fail"].(func(s string) (string, error))
-	s, err := fail("the fail message")
-	if err == nil {
-		t.Errorf("fail() error = %v, wantErr %v", err, errors.New("the fail message"))
-	}
-	if s != "" {
-		t.Errorf("fail() = \"%s\", want \"the fail message\"", s)
-	}
-	if failMesage != "the fail message" {
-		t.Errorf("fail() message = \"%s\", want \"the fail message\"", failMesage)
-	}
-}
-
 func TestWithTemplate(t *testing.T) {
 	key := mustGeneratePublicKey(t)
 	cr := CertificateRequest{
diff --git a/sshutil/templates_test.go b/sshutil/templates_test.go
index 8981d8e..f4be069 100644
--- a/sshutil/templates_test.go
+++ b/sshutil/templates_test.go
@@ -553,3 +553,60 @@ func TestTemplateData_SetCertificateRequest(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateTemplate(t *testing.T) {
+	tests := []struct {
+		name    string
+		text    []byte
+		wantErr bool
+	}{
+		{
+			name:    "ok",
+			text:    []byte(DefaultTemplate),
+			wantErr: false,
+		},
+		{
+			name:    "ok/invalid-json",
+			text:    []byte("{!?}"),
+			wantErr: false,
+		},
+		{
+			name:    "fail/unknown-function",
+			text:    []byte("{{ unknownFunction }}"),
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ValidateTemplate(tt.text); (err != nil) != tt.wantErr {
+				t.Errorf("ValidateTemplate() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidateTemplateData(t *testing.T) {
+	tests := []struct {
+		name    string
+		data    []byte
+		wantErr bool
+	}{
+		{
+			name:    "ok",
+			data:    []byte("{}"),
+			wantErr: false,
+		},
+		{
+			name:    "fail",
+			data:    []byte("{!?}"),
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ValidateTemplateData(tt.data); (err != nil) != tt.wantErr {
+				t.Errorf("ValidateTemplateData() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
diff --git a/x509util/options_test.go b/x509util/options_test.go
index db876f4..ead2d34 100644
--- a/x509util/options_test.go
+++ b/x509util/options_test.go
@@ -10,8 +10,6 @@ import (
 	"encoding/base64"
 	"reflect"
 	"testing"
-
-	"github.com/pkg/errors"
 )
 
 func createRSACertificateRequest(t *testing.T, bits int, commonName string, sans []string) (*x509.CertificateRequest, crypto.Signer) {
@@ -39,22 +37,6 @@ func createRSACertificateRequest(t *testing.T, bits int, commonName string, sans
 	return cr, priv
 }
 
-func Test_getFuncMap_fail(t *testing.T) {
-	var failMesage string
-	fns := getFuncMap(&failMesage)
-	fail := fns["fail"].(func(s string) (string, error))
-	s, err := fail("the fail message")
-	if err == nil {
-		t.Errorf("fail() error = %v, wantErr %v", err, errors.New("the fail message"))
-	}
-	if s != "" {
-		t.Errorf("fail() = \"%s\", want \"the fail message\"", s)
-	}
-	if failMesage != "the fail message" {
-		t.Errorf("fail() message = \"%s\", want \"the fail message\"", failMesage)
-	}
-}
-
 func TestWithTemplate(t *testing.T) {
 	cr, _ := createCertificateRequest(t, "foo", []string{"foo.com", "foo@foo.com", "::1", "https://foo.com"})
 	crRSA, _ := createRSACertificateRequest(t, 2048, "foo", []string{"foo.com", "foo@foo.com", "::1", "https://foo.com"})
diff --git a/x509util/templates_test.go b/x509util/templates_test.go
index a957654..1bfc1e8 100644
--- a/x509util/templates_test.go
+++ b/x509util/templates_test.go
@@ -332,3 +332,60 @@ func TestTemplateData_SetCertificateRequest(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateTemplate(t *testing.T) {
+	tests := []struct {
+		name    string
+		text    []byte
+		wantErr bool
+	}{
+		{
+			name:    "ok",
+			text:    []byte(DefaultLeafTemplate),
+			wantErr: false,
+		},
+		{
+			name:    "ok/invalid-json",
+			text:    []byte("{!?}"),
+			wantErr: false,
+		},
+		{
+			name:    "fail/unknown-function",
+			text:    []byte("{{ unknownFunction }}"),
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ValidateTemplate(tt.text); (err != nil) != tt.wantErr {
+				t.Errorf("ValidateTemplate() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidateTemplateData(t *testing.T) {
+	tests := []struct {
+		name    string
+		data    []byte
+		wantErr bool
+	}{
+		{
+			name:    "ok",
+			data:    []byte("{}"),
+			wantErr: false,
+		},
+		{
+			name:    "fail",
+			data:    []byte("{!?}"),
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ValidateTemplateData(tt.data); (err != nil) != tt.wantErr {
+				t.Errorf("ValidateTemplateData() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}