Files
vault-tools/shared/vault_test.go
T
unkinben 123faf8bbf
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
Add vctl and vctx Vault token CLIs
Introduces the vault-tools monorepo: two Go CLIs that share a config file
(~/.config/vault) and token cache (~/.cache/vault) for working with multiple
Vault instances (contexts).

- add shared/ library: config parsing (vctl.yaml/config.yaml, per-context
  overrides, slash contexts), token cache (0600/0700, atomic writes, path-
  traversal guards), and a small hand-rolled Vault HTTP client (login/renew)
- add vctl: login/renew (single or --all), list, --method/--user overrides,
  no-echo password/token prompts, dynamic context completion
- add vctx: resolve a context, set VAULT_ADDR/VAULT_TOKEN/VAULT_NAMESPACE and
  exec the vault CLI, passing remaining args through untouched
- add unit tests across shared/, vctl and vctx command layers (config
  resolution, cache paths, vault client, --all iteration + error aggregation,
  vctx arg pass-through and env construction via fakeable exec/prompt seams)
- add Makefile (build/test/completions/rpm, patch|minor|major version bumps),
  nfpm RPM packaging bundling bash/zsh/fish completions for both binaries
- add Woodpecker pipelines: build/test/pre-commit on PRs, and a tag release
  that cross-compiles, builds+uploads the RPM to artifactapi, and cuts a Gitea
  release (serviceAccountName default, k8s resources on every step)
- add README, per-command docs (docs/vctl.md, docs/vctx.md), AGENTS.md and an
  example config

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-26 23:28:05 +10:00

148 lines
4.5 KiB
Go

package shared
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNeedsPasswordAndTokenMethod(t *testing.T) {
for _, m := range []string{"ldap", "userpass", "okta", "radius"} {
if !NeedsPassword(m) {
t.Errorf("NeedsPassword(%q) = false, want true", m)
}
}
if NeedsPassword("token") {
t.Error("NeedsPassword(token) should be false")
}
if !IsTokenMethod("token") {
t.Error("IsTokenMethod(token) should be true")
}
if IsTokenMethod("ldap") {
t.Error("IsTokenMethod(ldap) should be false")
}
}
func TestLoginPasswordMethod(t *testing.T) {
var gotPath, gotNS string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotNS = r.Header.Get("X-Vault-Namespace")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","accessor":"acc","token_policies":["default","kv"],"lease_duration":3600,"renewable":true}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben", Namespace: "team-a"}
tok, err := Login(rc, "hunter2")
if err != nil {
t.Fatalf("Login: %v", err)
}
if gotPath != "/v1/auth/ldap/login/ben" {
t.Errorf("login path = %q", gotPath)
}
if gotNS != "team-a" {
t.Errorf("namespace header = %q", gotNS)
}
if gotBody["password"] != "hunter2" {
t.Errorf("password body = %v", gotBody)
}
if tok.Token != "s.tok" || tok.Accessor != "acc" || !tok.Renewable {
t.Errorf("token = %+v", tok)
}
if len(tok.Policies) != 2 || tok.Policies[0] != "default" {
t.Errorf("policies = %v", tok.Policies)
}
if tok.ExpiresAt.IsZero() {
t.Error("ExpiresAt should be set from lease_duration")
}
}
func TestLoginCustomAuthPath(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.x","lease_duration":60}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "legacy", Address: srv.URL, Method: "userpass", Path: "userpass2", User: "svc"}
if _, err := Login(rc, "pw"); err != nil {
t.Fatal(err)
}
if gotPath != "/v1/auth/userpass2/login/svc" {
t.Errorf("login path = %q, want custom auth path", gotPath)
}
}
func TestLoginTokenMethodUsesLookupSelf(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/auth/token/lookup-self" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if r.Header.Get("X-Vault-Token") != "s.raw" {
t.Errorf("token header = %q", r.Header.Get("X-Vault-Token"))
}
_, _ = io.WriteString(w, `{"data":{"accessor":"acc2","policies":["root"],"ttl":0,"renewable":false}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "root-ctx", Address: srv.URL, Method: "token"}
tok, err := Login(rc, "s.raw")
if err != nil {
t.Fatalf("Login(token): %v", err)
}
if tok.Token != "s.raw" || tok.Accessor != "acc2" {
t.Errorf("token = %+v", tok)
}
if !tok.ExpiresAt.IsZero() {
t.Error("ttl=0 should leave ExpiresAt zero")
}
}
func TestRenewPreservesAccessor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/auth/token/renew-self" {
t.Errorf("path = %q", r.URL.Path)
}
// renew response omits accessor; Renew should carry it from prev.
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","lease_duration":7200,"renewable":true}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "sydney", Address: srv.URL}
prev := &Token{Context: "sydney", Address: srv.URL, Token: "s.tok", Accessor: "acc-prev"}
tok, err := Renew(rc, prev)
if err != nil {
t.Fatalf("Renew: %v", err)
}
if tok.Accessor != "acc-prev" {
t.Errorf("accessor = %q, want carried-over acc-prev", tok.Accessor)
}
if tok.LeaseDurationSeconds != 7200 {
t.Errorf("lease = %d, want 7200", tok.LeaseDurationSeconds)
}
}
func TestLoginSurfacesVaultError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"errors":["ldap operation failed"]}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben"}
_, err := Login(rc, "bad")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "ldap operation failed") {
t.Errorf("error should surface vault message, got %v", err)
}
}