This page looks best with JavaScript enabled

Unit Testing With Vault in Go

 ·  ☕ 9 min read

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 !


Thomas
WRITTEN BY
Thomas
I am a Site Reliability Engineer, currently working from London. I hate that I like computers. I try to post potentially useful stuff from time to time.