Files
certmanager/internal/vault/client_test.go
T
unkin-agent b0aad96673
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
Source the CA from /ca_chain, not from issuance
Deriving ca_certificate from an issue response welds CA distribution to
per-node renewal, so a rotation takes ~90 days to reach the estate, and
issuing_ca is the intermediate alone, which strict path builders reject
as a CAfile.

- add a `ca` subcommand printing {mount}/ca_chain as PEM, unauthenticated
- drop ca_certificate from the issue document and its file output
- order full_chain leaf first
- require only vault.addr for the CA path via config.LoadCA
2026-09-19 13:27:47 +10:00

329 lines
8.7 KiB
Go

package vault_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/certmanager/internal/config"
"git.unkin.net/unkin/certmanager/internal/vault"
)
func newServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(h)
t.Cleanup(srv.Close)
return srv
}
func baseConfig(addr string) config.VaultConfig {
return config.VaultConfig{
Addr: addr,
AuthMethod: config.AuthMethodAppRole,
ApprolePath: "approle",
RoleID: "role-id",
MountPoint: "pki_int",
RoleName: "servers_default",
}
}
func TestNewAppRoleSendsRoleIDOnly(t *testing.T) {
var body map[string]any
var path string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
if _, err := vault.New(baseConfig(srv.URL)); err != nil {
t.Fatalf("New: %v", err)
}
if path != "/v1/auth/approle/login" {
t.Errorf("login path = %q", path)
}
if len(body) != 1 || body["role_id"] != "role-id" {
t.Errorf("login body = %v, want only role_id", body)
}
}
func TestNewKubernetesSendsRoleAndJWT(t *testing.T) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("the-jwt\n"), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
var body map[string]any
var path string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
cfg := baseConfig(srv.URL)
cfg.AuthMethod = config.AuthMethodKubernetes
cfg.K8sMount = "k8s/au/syd1"
cfg.K8sRole = "puppet_certmanager"
cfg.JWTPath = jwt
if _, err := vault.New(cfg); err != nil {
t.Fatalf("New: %v", err)
}
if path != "/v1/auth/k8s/au/syd1/login" {
t.Errorf("login path = %q", path)
}
if body["role"] != "puppet_certmanager" || body["jwt"] != "the-jwt" {
t.Errorf("login body = %v", body)
}
}
func TestNewAuthErrors(t *testing.T) {
deny := func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = fmt.Fprint(w, `{"errors":["permission denied"]}`)
}
noToken := func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"auth":{}}`)
}
tests := []struct {
name string
handler http.HandlerFunc
mutate func(*testing.T, *config.VaultConfig)
wantErr string
}{
{
name: "approle rejected",
handler: deny,
wantErr: "approle login: POST auth/approle/login: vault returned 403",
},
{
name: "approle returns no token",
handler: noToken,
wantErr: "no auth.client_token",
},
{
name: "kubernetes rejected",
handler: deny,
mutate: func(t *testing.T, c *config.VaultConfig) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("jwt"), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
c.AuthMethod = config.AuthMethodKubernetes
c.K8sMount = "k8s/au/syd1"
c.K8sRole = "role"
c.JWTPath = jwt
},
wantErr: "kubernetes login: POST auth/k8s/au/syd1/login: vault returned 403",
},
{
name: "kubernetes jwt missing",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) {
c.AuthMethod = config.AuthMethodKubernetes
c.K8sMount = "k8s/au/syd1"
c.K8sRole = "role"
c.JWTPath = filepath.Join(t.TempDir(), "absent")
},
wantErr: "read service account token",
},
{
name: "unknown auth method",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) { c.AuthMethod = "ldap" },
wantErr: `unknown vault.auth_method "ldap"`,
},
{
name: "bad timeout",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) { c.Timeout = "eventually" },
wantErr: "not a valid duration",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := newServer(t, tc.handler)
cfg := baseConfig(srv.URL)
if tc.mutate != nil {
tc.mutate(t, &cfg)
}
_, err := vault.New(cfg)
if err == nil {
t.Fatal("New succeeded, want error")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
}
})
}
}
func TestNewKubernetesEmptyJWT(t *testing.T) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("\n\t "), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
t.Error("vault must not be contacted when the JWT is empty")
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
cfg := baseConfig(srv.URL)
cfg.AuthMethod = config.AuthMethodKubernetes
cfg.K8sMount = "k8s/au/syd1"
cfg.K8sRole = "role"
cfg.JWTPath = jwt
_, err := vault.New(cfg)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("error = %v, want an empty-JWT failure", err)
}
}
func TestPostSendsToken(t *testing.T) {
var token string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
token = r.Header.Get("X-Vault-Token")
_, _ = fmt.Fprint(w, `{"data":{"ok":true}}`)
})
c, err := vault.New(baseConfig(srv.URL))
if err != nil {
t.Fatalf("New: %v", err)
}
var out struct {
Data struct {
OK bool `json:"ok"`
} `json:"data"`
}
if err := c.Post("pki_int/issue/servers_default", map[string]string{}, &out); err != nil {
t.Fatalf("Post: %v", err)
}
if token != "s.token" {
t.Errorf("X-Vault-Token = %q", token)
}
if !out.Data.OK {
t.Error("response not decoded")
}
}
func TestPostTimesOut(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
time.Sleep(500 * time.Millisecond)
_, _ = fmt.Fprint(w, `{"data":{}}`)
})
cfg := baseConfig(srv.URL)
cfg.Timeout = "50ms"
c, err := vault.New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
start := time.Now()
err = c.Post("pki_int/issue/servers_default", map[string]string{}, &struct{}{})
if err == nil {
t.Fatal("Post succeeded, want a timeout")
}
if elapsed := time.Since(start); elapsed > 400*time.Millisecond {
t.Errorf("Post took %v, the timeout did not apply", elapsed)
}
}
func TestTLSVerificationCanBeEnabled(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
cfg := baseConfig(srv.URL)
skip := false
cfg.TLSSkipVerify = &skip
if _, err := vault.New(cfg); err == nil {
t.Fatal("New succeeded against a self-signed server with verification enabled")
}
}
func TestNewUnauthenticatedDoesNotLogIn(t *testing.T) {
var paths []string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if _, ok := r.Header["X-Vault-Token"]; ok {
t.Errorf("X-Vault-Token sent to %s", r.URL.Path)
}
_, _ = fmt.Fprint(w, "PEM")
})
// No auth_method, no role_id, no JWT path: the CA chain endpoint is public.
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
if err != nil {
t.Fatalf("NewUnauthenticated: %v", err)
}
body, err := c.Get("pki_int/ca_chain")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(body) != "PEM" {
t.Errorf("body = %q", body)
}
if len(paths) != 1 || paths[0] != "/v1/pki_int/ca_chain" {
t.Errorf("requests = %v, want a single GET of the chain", paths)
}
}
func TestGetSendsTheTokenWhenAuthenticated(t *testing.T) {
var token string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
token = r.Header.Get("X-Vault-Token")
_, _ = fmt.Fprint(w, "PEM")
})
c, err := vault.New(baseConfig(srv.URL))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := c.Get("pki_int/ca_chain"); err != nil {
t.Fatalf("Get: %v", err)
}
if token != "s.token" {
t.Errorf("X-Vault-Token = %q", token)
}
}
func TestGetSurfacesTheStatusAndBody(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, `{"errors":["unsupported path"]}`)
})
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
if err != nil {
t.Fatalf("NewUnauthenticated: %v", err)
}
if _, err := c.Get("pki_int/ca_chain"); err == nil ||
!strings.Contains(err.Error(), "vault returned 404") ||
!strings.Contains(err.Error(), "unsupported path") {
t.Errorf("error = %v, want the status and Vault's message", err)
}
}