Recently I’ve been dealing with Hashicorp Vault a lot, and I’ve had to write a bunch of code to interface with it. This post is going to be about my least favourite part of writing code: unit testing what I wrote (as everyone knows, it works on my machine, right ?).
Assumptions
This article assumes that you are already familiar with what Vault is and the very basics of how to use it (get/put secrets, login and such). It also assumes that you have some knowledge of the Go programming language.
What we want
Now, we have several ways of unit testing things, and usually when you talk to external systems the easiest way of doing it is by mocking the system, and writing mocks that will give you a deterministic answer to a given input. That’s great and all but in my use case I wanted to be able to talk to a real Vault server, to be in more realistic testing conditions.
Fortunately, people at Hashicorp do things well, and they provide us with tooling that basically enables us to spin up a real (real!!) vault server for us to fuck around and perform all the testing we want, and I’m going to explain you how.
Setting up the stage
We will assume that we want to unit test a dummy function that will write a secret to Vault, and we want to make sure that the secret is written properly. Let’s write a simple main.go
file that contains our main
function, as well as a putSecret
function that will store our secret into Vault.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package main
import (
"fmt"
"github.com/hashicorp/vault/api"
"github.com/sirupsen/logrus"
)
// Write the secret
func putSecret(client *api.Client, secret map[string]interface{}, secretPath string) error {
_, err := client.Logical().Write(fmt.Sprintf("secret/data/%s", secretPath), secret)
return err
}
func main() {
client, err := api.NewClient(api.DefaultConfig())
if err != nil {
logrus.WithError(err).Fatal("Could not initialise vault client")
}
err = putSecret(client, map[string]interface{}{"foo": "bar"}, "baz")
if err != nil {
logrus.WithError(err).Fatal("Could not write secret")
}
}
|
This is a fairly simple main.go
that will just write a secret to the given path. Now how can we be sure it actually does the job ? We could just compile the program then run it against a local vault, but that just sounds tedious and annoying doesn’t it ? Instead how about we use the NewTestCluster
function provided by the github.com/hashicorp/vault/vault
package ?
Now let’s write some tests
You will need a main_test.go
file that will look just like that:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
package main
import (
"testing"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/vault"
)
const (
testVaultToken = "token" // This is the root token
)
func TestPutSecret(t *testing.T) {
cluster := vault.NewTestCluster(t, &vault.CoreConfig{
DevToken: testVaultToken,
}, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()
core := cluster.Cores[0].Core
vault.TestWaitActive(t, core)
client := cluster.Cores[0].Client
err := putSecret(client, map[string]interface{}{"foo": "bar"}, "secret")
if err != nil {
t.Fatal(err)
}
data, err := client.Logical().Read("secret/data/secret")
if err != nil {
t.Fatal(err)
}
if secret, ok := data.Data["foo"].(string); ok {
if secret != "bar" {
t.Fatalf("Wrong secret returned: %s", secret)
}
} else {
t.Fatal("Could not get secret")
}
}
|
Now let’s check it is working
1
2
|
$ go test -cover ./...
ok blog.thomas.maurice.fr/vault-test 3.625s coverage: 25.0% of statements
|
Woo! It worked. There is some things to unpack here so let’s have a look.
- First we import all the needed packages
- Then we start a dev
cluster
with a custom devToken
for the unit tests.
- We need to clean that up afterwards so we defer the
Cleanup
call
Then is an interesting part. In order to talk to Vault we need a client, and since Vault starts on a random port during the tests, generates its own CA, certificates and so on to listen on HTTPS, we need to retrieve it, which is achieved by just picking a client to one of the nodes of the cluster (yes, it starts in cluster mode!).
- Then we actually use the client to test that our method actually works. Isn’t it amazing ?
Pushing it further
Now, another issue I’ve had is that I was working with approles, and I pulled my hair for a while trying to figure out how to make it work in my tests. It turns out that you have to explicitly tell Vault to load the plugin in order for it to work.
I am too lazy to patch the main.go
to actually use an approle, but I will demonstrate how to enable it into your vault test server, so you can use it. Note that the same pattern applies to pretty much every authentication method you may want to use.
You will do the following (note the additional CredentialBackends
init option):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
package main
import (
"testing"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/builtin/credential/approle"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
)
const (
testVaultToken = "token" // This is the root token
)
func TestPutSecret(t *testing.T) {
cluster := vault.NewTestCluster(t, &vault.CoreConfig{
DevToken: testVaultToken,
CredentialBackends: map[string]logical.Factory{
"approle": approle.Factory,
},
}, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()
core := cluster.Cores[0].Core
vault.TestWaitActive(t, core)
client := cluster.Cores[0].Client
// Enable approle
err := client.Sys().EnableAuthWithOptions("approle", &api.EnableAuthOptions{
Type: "approle",
})
if err != nil {
t.Fatal(err)
}
// ... the rest of your unit test
}
|
In case you are wondering, the core
object above refers to a Vault node of the test cluster.
This way you can freely create an approle, and some policies (why not ?!), to use Vault as you would use it in prod:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// ..... The imports and shit
func TestMyTest(t *testing.T) {
// ... Vault initialization as above
// Create an approle
_, err = client.Logical().Write("auth/approle/role/unittest", map[string]interface{}{
"policies": []string{"unittest"},
})
if err != nil {
t.Fatal(err)
}
// Gets the role ID, that is basically the 'username' used to log into vault
res, err := client.Logical().Read("auth/approle/role/unittest/role-id")
if err != nil {
t.Fatal(err)
}
// Keep the roleID for later use
roleID, ok := res.Data["role_id"].(string)
if !ok {
t.Fatal("Could not read the approle")
}
// Create a secretID that is basically the password for the approle
res, err = client.Logical().Write("auth/approle/role/unittest/secret-id", nil)
if err != nil {
t.Fatal(err)
}
// Use thre secretID later
secretID, ok := res.Data["secret_id"].(string)
if !ok {
t.Fatal("Could not generate the secret id")
}
// Create a broad policy to allow the approle to do whatever
err = client.Sys().PutPolicy("unittest", `
path "*" {
capabilities = ["create", "read", "list", "update", delete]
}
`)
if err != nil {
t.Fatal(err)
}
}
|
Reusing the code
If you have been paying attention to what I spent a few hours writing, something should have struck you, and that thing is there:
1
2
|
$ go test -cover ./...
ok blog.thomas.maurice.fr/vault-test 3.625s coverage: 25.0% of statements
|
Specifically the 3.625s
part of it. Right now you might just be shrieking in rage and disappointment like
Holy shit this is slow as fuck, there is now way I am ever using that this is going to slow down my CI mate
Which is fair enough, which is why there is something you can do about it. You can create some mocks.go
file that would contain all the Vault initialization code that you can reuse across multiple tests, something along the lines of:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
|
package main
import (
"testing"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/builtin/credential/approle"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
)
const (
// TestVaultToken is the Vault token used for tests
testVaultToken = "unittesttoken"
)
type vaultTest struct {
Cluster *vault.TestCluster
Client *api.Client
AppRoleID string
AppRoleSecret string
}
// creates the test server
func GetTestVaultServer(t *testing.T) vaultTest {
t.Helper()
cluster := vault.NewTestCluster(t, &vault.CoreConfig{
DevToken: testVaultToken,
CredentialBackends: map[string]logical.Factory{
"approle": approle.Factory,
},
}, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
core := cluster.Cores[0].Core
vault.TestWaitActive(t, core)
client := cluster.Cores[0].Client
// Create an approle
_, err = client.Logical().Write("auth/approle/role/unittest", map[string]interface{}{
"policies": []string{"unittest"},
})
if err != nil {
t.Fatal(err)
}
// Gets the role ID, that is basically the 'username' used to log into vault
res, err := client.Logical().Read("auth/approle/role/unittest/role-id")
if err != nil {
t.Fatal(err)
}
// Keep the roleID for later use
roleID, ok := res.Data["role_id"].(string)
if !ok {
t.Fatal("Could not read the approle")
}
// Create a secretID that is basically the password for the approle
res, err = client.Logical().Write("auth/approle/role/unittest/secret-id", nil)
if err != nil {
t.Fatal(err)
}
// Use thre secretID later
secretID, ok := res.Data["secret_id"].(string)
if !ok {
t.Fatal("Could not generate the secret id")
}
// Create a broad policy to allow the approle to do whatever
err = client.Sys().PutPolicy("unittest", `
path "*" {
capabilities = ["create", "read", "list", "update", delete]
}
`)
if err != nil {
t.Fatal(err)
}
return vaultTest{
Cluster: cluster,
Client: client,
AppRoleID: roleID,
AppRolESecret: secretID,
}
}
|
The only thing now is that it’s on you to call testVault.Cluster.Cleanup()
whenever you are done.
Few words of warning: This assumes that the things you will do to your Vault are without any side effects to other tests, such as RO operations (retrieving tokens and certificates for exeamples) or operations that are restricted to a certain path that belongs to your unit test.
Wrapping up
I hope that was helpful, happy unit testing.
Credits
Big thanks to daenney and kdevroede for the review !