Repository: smallstep/cli
Pull request #737 (2022-08-25 03:15:08)

Acme Challenge management
### Description

This PR adds support for managing the ACME provisioner challenges using `step ca provisioner add` and `step ca provisioner update`.


Patch:
diff --git a/command/ca/provisioner/add.go b/command/ca/provisioner/add.go
index 87e2f3eb..4b55972a 100644
--- a/command/ca/provisioner/add.go
+++ b/command/ca/provisioner/add.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/sliceutil"
 	"github.com/smallstep/cli/utils"
 	"github.com/urfave/cli"
 	"go.step.sm/cli-utils/errs"
@@ -84,7 +85,8 @@ IID
 
 ACME
 
-**step ca provisioner add** <name> **--type**=ACME [**--force-cn**] [**--require-eab**]
+**step ca provisioner add** <name> **--type**=ACME
+[**--force-cn**] [**--require-eab**] [**--challenge**=<challenge>]
 [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-provisioner**=<name>]
 [**--admin-subject**=<subject>] [**--password-file**=<file>] [**--ca-url**=<uri>]
 [**--root**=<file>] [**--context**=<name>] [**--ca-config**=<file>]
@@ -122,11 +124,13 @@ SCEP
 			nebulaRootFlag,
 
 			// ACME provisioner flags
-			forceCNFlag,
 			requireEABFlag,
 
+			// ACME and SCEP provisioner flags
+			forceCNFlag,
+			challengeFlag,
+
 			// SCEP provisioner flags
-			scepChallengeFlag,
 			scepCapabilitiesFlag,
 			scepIncludeRootFlag,
 			scepMinimumPublicKeyLengthFlag,
@@ -294,6 +298,11 @@ func addAction(ctx *cli.Context) (err error) {
 		Type: linkedca.Provisioner_Type(typ),
 	}
 
+	// Validate challenge flag on scep and acme
+	if err := validateChallengeFlag(ctx, p.Type); err != nil {
+		return err
+	}
+
 	// Read x509 template if passed
 	p.X509Template = &linkedca.Template{}
 	if x509TemplateFile != "" {
@@ -541,6 +550,7 @@ func createACMEDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) {
 			ACME: &linkedca.ACMEProvisioner{
 				ForceCn:    ctx.Bool("force-cn"),
 				RequireEab: ctx.Bool("require-eab"),
+				Challenges: sliceutil.RemoveDuplicates(acmeChallengeToLinkedca(ctx.StringSlice("challenge"))),
 			},
 		},
 	}, nil
@@ -748,11 +758,16 @@ func createGCPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) {
 }
 
 func createSCEPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) {
+	var challenge string
+	// We have already validated that at most 1 is provided.
+	if v := ctx.StringSlice("challenge"); len(v) > 0 {
+		challenge = v[0]
+	}
 	return &linkedca.ProvisionerDetails{
 		Data: &linkedca.ProvisionerDetails_SCEP{
 			SCEP: &linkedca.SCEPProvisioner{
 				ForceCn:                       ctx.Bool("force-cn"),
-				Challenge:                     ctx.String("challenge"),
+				Challenge:                     challenge,
 				Capabilities:                  ctx.StringSlice("capabilities"),
 				MinimumPublicKeyLength:        int32(ctx.Int("min-public-key-length")),
 				IncludeRoot:                   ctx.Bool("include-root"),
@@ -761,3 +776,49 @@ func createSCEPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) {
 		},
 	}, nil
 }
+
+func validateChallengeFlag(ctx *cli.Context, typ linkedca.Provisioner_Type) error {
+	switch typ {
+	case linkedca.Provisioner_ACME:
+		for _, v := range ctx.StringSlice("challenge") {
+			switch strings.ToLower(v) {
+			case "http-01", "dns-01", "tls-alpn-01", "device-attest-01":
+			default:
+				return errs.InvalidFlagValue(ctx, "challenge", v, "http-01, dns-01, tls-alpn-01 and device-attest-01")
+			}
+		}
+		for _, v := range ctx.StringSlice("remove-challenge") {
+			switch strings.ToLower(v) {
+			case "http-01", "dns-01", "tls-alpn-01", "device-attest-01":
+			default:
+				return errs.InvalidFlagValue(ctx, "remove-challenge", v, "http-01, dns-01, tls-alpn-01 and device-attest-01")
+			}
+		}
+
+	case linkedca.Provisioner_SCEP:
+		if len(ctx.StringSlice("challenge")) > 1 {
+			return errors.New("provisioner type 'SCEP' does not support multiple '--challenge' flags")
+		}
+	}
+	return nil
+}
+
+// acmeChallengeToLinkedca returns the linkedca challenge types on the challenge
+// flag. It won't fail or add unsupported flags, the function assumes the
+// options have been previously validated.
+func acmeChallengeToLinkedca(challenges []string) []linkedca.ACMEProvisioner_ChallengeType {
+	var ret []linkedca.ACMEProvisioner_ChallengeType
+	for _, v := range challenges {
+		switch strings.ToLower(v) {
+		case "http-01":
+			ret = append(ret, linkedca.ACMEProvisioner_HTTP_01)
+		case "dns-01":
+			ret = append(ret, linkedca.ACMEProvisioner_DNS_01)
+		case "tls-alpn-01":
+			ret = append(ret, linkedca.ACMEProvisioner_TLS_ALPN_O1)
+		case "device-attest-01":
+			ret = append(ret, linkedca.ACMEProvisioner_DEVICE_ATTEST_01)
+		}
+	}
+	return ret
+}
diff --git a/command/ca/provisioner/provisioner.go b/command/ca/provisioner/provisioner.go
index f70d466c..6bbb6b46 100644
--- a/command/ca/provisioner/provisioner.go
+++ b/command/ca/provisioner/provisioner.go
@@ -314,10 +314,54 @@ disable ssh use '--ssh=false'.`,
 containing one or more PEM formatted keys, if used with the K8SSA provisioner.`,
 	}
 
+	// ACME and SCEP provisioner flags
 	forceCNFlag = cli.BoolFlag{
 		Name:  "force-cn",
 		Usage: `Always set the common name in provisioned certificates.`,
 	}
+
+	challengeFlag = cli.StringSliceFlag{
+		Name: "challenge",
+		Usage: `With a SCEP provisioner the <challenge> is a shared secret between a
+client and the CA.
+
+With an ACME provisioner, this flag specifies the <challenge> or challenges to
+enable. Use the flag multiple times to configure multiple challenges.
+
+The supported ACME challenges are:
+
+**http-01**
+: With the HTTP challenge, the client in an ACME transaction proves its control
+over a domain name by proving that it can provision HTTP resources on a server
+accessible under that domain name.
+
+**dns-01**
+: With the DNS challenge, the client can prove control of a domain by
+provisioning a TXT resource record containing a designated value for a specific
+validation domain name.
+
+**tls-alpn-01**
+: With the TLS with Application-Layer Protocol Negotiation (TLS ALPN) challenge,
+the client can prove control over a domain name by configuring a TLS server to
+respond to specific connection attempts using the ALPN extension with
+identifying information.
+
+**device-attest-01**
+: With the device attestation challenge, the client can prove control over a
+permanent identifier of a device by providing an attestation statement
+containing the identifier of the device.
+
+If the provisioner has no challenges configured, http-01, dns-01 and tls-alpn-01
+will be automatically enabled.`,
+	}
+
+	removeChallengeFlag = cli.StringSliceFlag{
+		Name: "remove-challenge",
+		Usage: `Remove an ACME <challenge> from the list configured in the provisioner.
+Use the flag multiple times to remove multiple challenges.`,
+	}
+
+	// ACME provisioner flags
 	requireEABFlag = cli.BoolFlag{
 		Name: "require-eab",
 		Usage: `Require (and enable) External Account Binding (EAB) for Account creation.
@@ -325,10 +369,6 @@ If this flag is set to false, then disable EAB.`,
 	}
 
 	// SCEP provisioner flags
-	scepChallengeFlag = cli.StringFlag{
-		Name:  "challenge",
-		Usage: `The SCEP <challenge> to use as a shared secret between a client and the CA`,
-	}
 	scepCapabilitiesFlag = cli.StringSliceFlag{
 		Name:  "capabilities",
 		Usage: `The SCEP <capabilities> to advertise`,
diff --git a/command/ca/provisioner/update.go b/command/ca/provisioner/update.go
index 35610fe6..4862b16a 100644
--- a/command/ca/provisioner/update.go
+++ b/command/ca/provisioner/update.go
@@ -13,6 +13,7 @@ import (
 	"github.com/pkg/errors"
 	"github.com/smallstep/certificates/ca"
 	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/sliceutil"
 	"github.com/smallstep/cli/utils"
 	"github.com/urfave/cli"
 	"go.step.sm/cli-utils/errs"
@@ -36,6 +37,7 @@ func updateCommand() cli.Command {
 ACME
 
 **step ca provisioner update** <name> [**--force-cn**] [**--require-eab**]
+[**--challenge**=<challenge>] [**--remove-challenge**=<challenge>]
 [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-provisioner**=<name>]
 [**--admin-subject**=<subject>] [**--password-file**=<file>] [**--ca-url**=<uri>]
 [**--root**=<file>] [**--context**=<name>] [**--ca-config**=<file>]
@@ -115,11 +117,12 @@ SCEP
 			nebulaRootFlag,
 
 			// ACME provisioner flags
-			forceCNFlag,
-			requireEABFlag,
+			requireEABFlag,      // ACME
+			forceCNFlag,         // ACME + SCEP
+			challengeFlag,       // ACME + SCEP
+			removeChallengeFlag, // ACME
 
 			// SCEP flags
-			scepChallengeFlag,
 			scepCapabilitiesFlag,
 			scepIncludeRootFlag,
 			scepMinimumPublicKeyLengthFlag,
@@ -280,6 +283,11 @@ func updateAction(ctx *cli.Context) (err error) {
 		return err
 	}
 
+	// Validate challenge flag on scep and acme
+	if err := validateChallengeFlag(ctx, p.Type); err != nil {
+		return err
+	}
+
 	if ctx.IsSet("name") {
 		p.Name = ctx.String("name")
 	}
@@ -590,6 +598,17 @@ func updateACMEDetails(ctx *cli.Context, p *linkedca.Provisioner) error {
 	if ctx.IsSet("require-eab") {
 		details.RequireEab = ctx.Bool("require-eab")
 	}
+	if ctx.IsSet("remove-challenge") {
+		values := acmeChallengeToLinkedca(ctx.StringSlice("remove-challenge"))
+		details.Challenges = sliceutil.RemoveValues(details.Challenges, values)
+	}
+	if ctx.IsSet("challenge") {
+		values := acmeChallengeToLinkedca(ctx.StringSlice("challenge"))
+		details.Challenges = append(details.Challenges, values...)
+	}
+	if ctx.IsSet("challenge") || ctx.IsSet("remove-challenge") {
+		details.Challenges = sliceutil.RemoveDuplicates(details.Challenges)
+	}
 	return nil
 }
 
diff --git a/go.mod b/go.mod
index 241f887a..3931657d 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/shurcooL/sanitized_anchor_name v1.0.0
 	github.com/slackhq/nebula v1.5.2
 	github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262
-	github.com/smallstep/certificates v0.22.0
+	github.com/smallstep/certificates v0.22.2-0.20220908202628-b2119e9f2c68
 	github.com/smallstep/certinfo v1.7.0
 	github.com/smallstep/truststore v0.12.0
 	github.com/smallstep/zcrypto v0.0.0-20210924233136-66c2600f6e71
@@ -23,8 +23,8 @@ require (
 	github.com/urfave/cli v1.22.5
 	go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352
 	go.step.sm/cli-utils v0.7.5
-	go.step.sm/crypto v0.18.0
-	go.step.sm/linkedca v0.18.0
+	go.step.sm/crypto v0.19.0
+	go.step.sm/linkedca v0.18.1-0.20220908140115-147cf75c525d
 	golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
 	golang.org/x/net v0.0.0-20220607020251-c690dde0001d
 	golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d
@@ -81,6 +81,7 @@ require (
 	github.com/envoyproxy/protoc-gen-validate v0.3.0-java // indirect
 	github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
 	github.com/fullstorydev/grpcurl v1.8.2 // indirect
+	github.com/fxamacker/cbor/v2 v2.4.0 // indirect
 	github.com/go-chi/chi v4.1.2+incompatible // indirect
 	github.com/go-kit/kit v0.10.0 // indirect
 	github.com/go-logfmt/logfmt v0.5.0 // indirect
@@ -149,6 +150,7 @@ require (
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect
 	github.com/weppos/publicsuffix-go v0.4.0 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
 	github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
 	go.etcd.io/bbolt v1.3.6 // indirect
 	go.etcd.io/etcd/api/v3 v3.5.0 // indirect
diff --git a/go.sum b/go.sum
index 77ecc6b8..d8bac859 100644
--- a/go.sum
+++ b/go.sum
@@ -329,6 +329,8 @@ github.com/fullstorydev/grpcurl v1.8.0/go.mod h1:Mn2jWbdMrQGJQ8UD62uNyMumT2acsZU
 github.com/fullstorydev/grpcurl v1.8.1/go.mod h1:3BWhvHZwNO7iLXaQlojdg5NA6SxUDePli4ecpK1N7gw=
 github.com/fullstorydev/grpcurl v1.8.2 h1:2II5e++aFnctnPJir3GL6cPSwF69Ord1u/9O+fv1vrI=
 github.com/fullstorydev/grpcurl v1.8.2/go.mod h1:YvWNT3xRp2KIRuvCphFodG0fKkMXwaxA9CJgKCcyzUQ=
+github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88=
+github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
 github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs=
 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -889,8 +891,8 @@ github.com/slackhq/nebula v1.5.2/go.mod h1:xaCM6wqbFk/NRmmUe1bv88fWBm3a1UioXJVIp
 github.com/smallstep/assert v0.0.0-20180720014142-de77670473b5/go.mod h1:TC9A4+RjIOS+HyTH7wG17/gSqVv95uDw2J64dQZx7RE=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
-github.com/smallstep/certificates v0.22.0 h1:Xrj3ZPw/+V9j4sBu21GEK0WfVriHG4LQODmLr/K2B64=
-github.com/smallstep/certificates v0.22.0/go.mod h1:3R1PxdLOEqbNaWp88WiUByQAHGL+b9NefaQ5q6oTIZo=
+github.com/smallstep/certificates v0.22.2-0.20220908202628-b2119e9f2c68 h1:s5S1IZJM4hgK78d/QQov2Vx3Re5GBbb+r4mIaCo7iJk=
+github.com/smallstep/certificates v0.22.2-0.20220908202628-b2119e9f2c68/go.mod h1:rGaw/vJYWunycyCMNu2Pq2XHVjtRNMdNO3U1gOSOfcQ=
 github.com/smallstep/certinfo v1.7.0 h1:1VzGgeSFLdXO242mRUkJu02epXV6E9uawjhPH0H2LzA=
 github.com/smallstep/certinfo v1.7.0/go.mod h1:QRjP6s+cuishA6cdB//RX357ysYGz/QxlpWGyWjnfII=
 github.com/smallstep/nosql v0.4.0 h1:Go3WYwttUuvwqMtFiiU4g7kBIlY+hR0bIZAqVdakQ3M=
@@ -974,6 +976,8 @@ github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU=
 github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/weppos/publicsuffix-go v0.4.0 h1:YSnfg3V65LcCFKtIGKGoBhkyKolEd0hlipcXaOjdnQw=
 github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xanzy/go-gitlab v0.31.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
 github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
@@ -1069,10 +1073,10 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
 go.step.sm/cli-utils v0.7.5 h1:jyp6X8k8mN1B0uWJydTid0C++8tQhm2kaaAdXKQQzdk=
 go.step.sm/cli-utils v0.7.5/go.mod h1:taSsY8haLmXoXM3ZkywIyRmVij/4Aj0fQbNTlJvv71I=
 go.step.sm/crypto v0.9.0/go.mod h1:+CYG05Mek1YDqi5WK0ERc6cOpKly2i/a5aZmU1sfGj0=
-go.step.sm/crypto v0.18.0 h1:saD/tMG7uKJmUIPyOyudidVTHPnozTU02CDd+oqwKn0=
-go.step.sm/crypto v0.18.0/go.mod h1:qZ+pNU1nV+THwP7TPTNCRMRr9xrRURhETTAK7U5psfw=
-go.step.sm/linkedca v0.18.0 h1:uxRBd2WDvJNZ2i0nJm/QmG4lkRxWoebYKJinchX7T7o=
-go.step.sm/linkedca v0.18.0/go.mod h1:qSuYlIIhvPmA2+DSSS03E2IXhbXWTLW61Xh9zDQJ3VM=
+go.step.sm/crypto v0.19.0 h1:WxjUDeTDpuPZ1IR3v6c4jc6WdlQlS5IYYQBhfnG5uW0=
+go.step.sm/crypto v0.19.0/go.mod h1:qZ+pNU1nV+THwP7TPTNCRMRr9xrRURhETTAK7U5psfw=
+go.step.sm/linkedca v0.18.1-0.20220908140115-147cf75c525d h1:grL/lXVzdUc51Aa2exEsRJl+Q9eaUwHc31heb/ims58=
+go.step.sm/linkedca v0.18.1-0.20220908140115-147cf75c525d/go.mod h1:qSuYlIIhvPmA2+DSSS03E2IXhbXWTLW61Xh9zDQJ3VM=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
diff --git a/internal/sliceutil/sliceutil.go b/internal/sliceutil/sliceutil.go
new file mode 100644
index 00000000..b7acdc7b
--- /dev/null
+++ b/internal/sliceutil/sliceutil.go
@@ -0,0 +1,41 @@
+package sliceutil
+
+// RemoveValues remove the given values from the given slice and returns the
+// updated one. It retains the order of elements in the source slice.
+func RemoveValues[T comparable](slice, values []T) []T {
+	if len(slice) == 0 {
+		return slice
+	}
+	keys := make(map[T]struct{}, len(slice))
+	for _, v := range values {
+		keys[v] = struct{}{}
+	}
+
+	var i int
+	for _, v := range slice {
+		if _, ok := keys[v]; !ok {
+			slice[i] = v
+			i++
+		}
+	}
+	return slice[:i]
+}
+
+// RemoveDuplicates returns a new slice of T with duplicate values removed. It
+// retains the order of elements in the source slice.
+func RemoveDuplicates[T comparable](slice []T) []T {
+	if len(slice) <= 1 {
+		return slice
+	}
+
+	keys := make(map[T]struct{}, len(slice))
+	ret := make([]T, 0, len(slice))
+	for _, v := range slice {
+		if _, ok := keys[v]; ok {
+			continue
+		}
+		keys[v] = struct{}{}
+		ret = append(ret, v)
+	}
+	return ret
+}


Test patch:
diff --git a/internal/sliceutil/sliceutil_test.go b/internal/sliceutil/sliceutil_test.go
new file mode 100644
index 00000000..77e75640
--- /dev/null
+++ b/internal/sliceutil/sliceutil_test.go
@@ -0,0 +1,51 @@
+package sliceutil
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestRemoveValues(t *testing.T) {
+	type args struct {
+		slice  []int
+		values []int
+	}
+	tests := []struct {
+		name string
+		args args
+		want []int
+	}{
+		{"ok", args{[]int{1, 2, 3, 4, 5}, []int{1, 5, 2}}, []int{3, 4}},
+		{"ok len(0)", args{[]int{}, []int{1, 5, 2}}, []int{}},
+		{"ok not found", args{[]int{1, 2, 3, 4, 5}, []int{6, 7, 8}}, []int{1, 2, 3, 4, 5}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := RemoveValues(tt.args.slice, tt.args.values); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("RemoveValues() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRemoveDuplicates(t *testing.T) {
+	type args struct {
+		slice []int
+	}
+	tests := []struct {
+		name string
+		args args
+		want []int
+	}{
+		{"ok", args{[]int{1, 1, 2, 3, 4, 2, 5, 5, 1}}, []int{1, 2, 3, 4, 5}},
+		{"ok len(0)", args{[]int{}}, []int{}},
+		{"ok len(1)", args{[]int{1}}, []int{1}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := RemoveDuplicates(tt.args.slice); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("RemoveDuplicates() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}