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
121 lines
3.2 KiB
Go
121 lines
3.2 KiB
Go
package shared
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTokenPathSimpleAndSlash(t *testing.T) {
|
|
base := t.TempDir()
|
|
t.Setenv("XDG_CACHE_HOME", base)
|
|
|
|
cases := map[string]string{
|
|
"sydney": filepath.Join(base, appDir, "sydney"),
|
|
"staging/sydney": filepath.Join(base, appDir, "staging", "sydney"),
|
|
"a/b/c": filepath.Join(base, appDir, "a", "b", "c"),
|
|
}
|
|
for ctx, want := range cases {
|
|
got, err := TokenPath(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TokenPath(%q): %v", ctx, err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("TokenPath(%q) = %q, want %q", ctx, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenPathRejectsTraversal(t *testing.T) {
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
bad := []string{"", "/etc/passwd", "../escape", "a/../../b", "a//b", "foo/", "./x"}
|
|
for _, ctx := range bad {
|
|
if _, err := TokenPath(ctx); err == nil {
|
|
t.Errorf("TokenPath(%q) expected error, got nil", ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveLoadRoundTripSlashContext(t *testing.T) {
|
|
base := t.TempDir()
|
|
t.Setenv("XDG_CACHE_HOME", base)
|
|
|
|
tok := &Token{
|
|
Context: "staging/sydney",
|
|
Address: "https://vault-staging.syd1.au.unkin.net",
|
|
Namespace: "staging",
|
|
Token: "s.abcdef123456",
|
|
Accessor: "acc-123",
|
|
Policies: []string{"default", "kv-read"},
|
|
Renewable: true,
|
|
LeaseDurationSeconds: 3600,
|
|
IssuedAt: time.Now().UTC().Truncate(time.Second),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour).Truncate(time.Second),
|
|
}
|
|
if err := SaveToken(tok); err != nil {
|
|
t.Fatalf("SaveToken: %v", err)
|
|
}
|
|
|
|
// Parent dir for a slash context must be created 0700, file 0600.
|
|
path, _ := TokenPath("staging/sydney")
|
|
if runtime.GOOS != "windows" {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("token file perm = %o, want 600", perm)
|
|
}
|
|
di, err := os.Stat(filepath.Dir(path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perm := di.Mode().Perm(); perm != 0o700 {
|
|
t.Errorf("token dir perm = %o, want 700", perm)
|
|
}
|
|
}
|
|
|
|
got, err := LoadToken("staging/sydney")
|
|
if err != nil {
|
|
t.Fatalf("LoadToken: %v", err)
|
|
}
|
|
if got.Token != tok.Token || got.Accessor != tok.Accessor || got.Namespace != tok.Namespace {
|
|
t.Errorf("round-trip mismatch: %+v vs %+v", got, tok)
|
|
}
|
|
if len(got.Policies) != 2 || got.Policies[1] != "kv-read" {
|
|
t.Errorf("policies mismatch: %v", got.Policies)
|
|
}
|
|
if !got.Renewable {
|
|
t.Error("renewable lost in round-trip")
|
|
}
|
|
}
|
|
|
|
func TestLoadTokenMissingIsNotExist(t *testing.T) {
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
_, err := LoadToken("never-logged-in")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing token")
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("error should wrap os.ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteToken(t *testing.T) {
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
tok := &Token{Context: "sydney", Address: "https://v", Token: "s.x"}
|
|
if err := SaveToken(tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := DeleteToken("sydney"); err != nil {
|
|
t.Fatalf("DeleteToken: %v", err)
|
|
}
|
|
// Deleting again is a no-op (not-exist tolerated).
|
|
if err := DeleteToken("sydney"); err != nil {
|
|
t.Errorf("second DeleteToken: %v", err)
|
|
}
|
|
}
|