123faf8bbf
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
148 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|