Files
vault-plugin-secrets-bind-tsig/backend_test.go
T
unkinben b4b8915d3e
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Scaffold the bind-tsig secrets engine
A Vault/OpenBao secrets engine that manages BIND TSIG keys via the
bind-operator companion API (Vault -> HTTP API -> BindTSIGKey CRs).

- backend + cmd entry point (plugin.ServeMultiplex), modelled on
  vault-plugin-secrets-litellm
- config path: companion API url/token/tls + defaults
- static-roles/static-creds: stable named key with managed rotation
- roles/creds: dynamic, lease-bound keys (revoke deletes the CR)
- tsig_key secret type with revoke/renew
- HTTP client for the companion API contract (/v1/keys CRUD + rotate)
- Makefile, Woodpecker CI (pre-commit/build/test + tag release RPMs),
  nfpm packaging (vault + openbao flavours)
- e2e: mock companion API + Vault + OpenBao in docker-compose, full
  lifecycle per engine; unit tests for the dynamic + static flows
2026-07-15 21:29:33 +10:00

157 lines
4.5 KiB
Go

package bindtsig
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func getTestBackend(t *testing.T) (*bindTSIGBackend, logical.Storage) {
t.Helper()
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = logical.TestSystemView()
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("creating backend: %v", err)
}
return b.(*bindTSIGBackend), config.StorageView
}
// mockAPI is an in-memory companion API implementing the /v1/keys contract.
type mockAPI struct {
server *httptest.Server
mu sync.Mutex
keys map[string]*tsigKey
}
func newMockAPI(t *testing.T) *mockAPI {
t.Helper()
m := &mockAPI{keys: map[string]*tsigKey{}}
m.server = httptest.NewServer(http.HandlerFunc(m.handle))
t.Cleanup(m.server.Close)
return m
}
func (m *mockAPI) handle(w http.ResponseWriter, r *http.Request) {
name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/keys"), "/")
rotate := strings.HasSuffix(name, "/rotate")
name = strings.TrimSuffix(name, "/rotate")
m.mu.Lock()
defer m.mu.Unlock()
switch {
case r.Method == http.MethodPost && name == "":
var req tsigKey
_ = json.NewDecoder(r.Body).Decode(&req)
k := &tsigKey{Name: req.Name, Algorithm: "hmac-sha256", Secret: "s3cr3t-" + req.Name, KeyName: req.Name, ClusterRef: req.ClusterRef}
m.keys[req.Name] = k
_ = json.NewEncoder(w).Encode(k)
case r.Method == http.MethodPost && rotate:
k := m.keys[name]
if k == nil {
http.NotFound(w, r)
return
}
k.Secret = k.Secret + "-rot"
_ = json.NewEncoder(w).Encode(k)
case r.Method == http.MethodGet:
k := m.keys[name]
if k == nil {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(k)
case r.Method == http.MethodDelete:
delete(m.keys, name)
w.WriteHeader(http.StatusNoContent)
}
}
func TestDynamicCredentialLifecycle(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockAPI(t)
// config
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation, Path: "config", Storage: s,
Data: map[string]interface{}{"api_url": m.server.URL, "default_cluster_ref": "bind-authoritative"},
}); err != nil {
t.Fatalf("config: %v", err)
}
// role
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation, Path: "roles/ephemeral", Storage: s,
Data: map[string]interface{}{"ttl": "1h", "max_ttl": "24h"},
}); err != nil {
t.Fatalf("role: %v", err)
}
// mint
resp, err := b.HandleRequest(ctx, &logical.Request{Operation: logical.ReadOperation, Path: "creds/ephemeral", Storage: s})
if err != nil || resp == nil || resp.Secret == nil {
t.Fatalf("creds: err=%v resp=%v", err, resp)
}
name := resp.Data["name"].(string)
if !strings.HasPrefix(name, "dyn-ephemeral-") {
t.Fatalf("unexpected dynamic name %q", name)
}
if _, ok := m.keys[name]; !ok {
t.Fatalf("key %q not created in companion API", name)
}
if resp.Data["secret"].(string) == "" {
t.Fatal("empty secret returned")
}
// revoke -> deleted
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.RevokeOperation, Path: "creds/ephemeral", Storage: s, Secret: resp.Secret,
}); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := m.keys[name]; ok {
t.Fatalf("key %q still present after revoke", name)
}
}
func TestStaticRoleProvisionAndRead(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockAPI(t)
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation, Path: "config", Storage: s,
Data: map[string]interface{}{"api_url": m.server.URL},
}); err != nil {
t.Fatalf("config: %v", err)
}
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation, Path: "static-roles/client-update", Storage: s,
Data: map[string]interface{}{"rotation_period": "720h"},
}); err != nil {
t.Fatalf("static-role: %v", err)
}
if _, ok := m.keys["client-update"]; !ok {
t.Fatal("static key not provisioned in companion API")
}
resp, err := b.HandleRequest(ctx, &logical.Request{Operation: logical.ReadOperation, Path: "static-creds/client-update", Storage: s})
if err != nil || resp == nil {
t.Fatalf("static-creds: err=%v resp=%v", err, resp)
}
if resp.Data["secret"].(string) == "" {
t.Fatal("static-creds returned empty secret")
}
if resp.Data["key_name"].(string) != "client-update" {
t.Fatalf("unexpected key_name %v", resp.Data["key_name"])
}
}