package main import ( "errors" "os" "path/filepath" "reflect" "strings" "testing" "git.unkin.net/unkin/vault-tools/shared" ) // setupVctx points XDG dirs at temp locations, writes a config file and the // given cached tokens, and puts a fake `vault` binary on PATH. It returns the // fake vault's absolute path. func setupVctx(t *testing.T, cfg string, tokens map[string]shared.Token) string { t.Helper() cfgHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", cfgHome) t.Setenv("XDG_CACHE_HOME", t.TempDir()) cfgDir := filepath.Join(cfgHome, "vault") if err := os.MkdirAll(cfgDir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfgDir, "vctl.yaml"), []byte(cfg), 0o600); err != nil { t.Fatal(err) } for name, tok := range tokens { tk := tok tk.Context = name if err := shared.SaveToken(&tk); err != nil { t.Fatal(err) } } binDir := t.TempDir() vault := filepath.Join(binDir, "vault") if err := os.WriteFile(vault, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) return vault } const vctxConfig = ` contexts: sydney: address: https://vault.syd1.au.unkin.net staging/sydney: address: https://vault-staging.syd1.au.unkin.net namespace: staging ` // lastVal returns the value of the last occurrence of key in an env slice, // mirroring exec's "last assignment wins" semantics. func lastVal(env []string, key string) (string, bool) { val, ok := "", false for _, e := range env { if strings.HasPrefix(e, key+"=") { val, ok = e[len(key)+1:], true } } return val, ok } func TestBuildInvocationArgsAndEnvOverride(t *testing.T) { vault := setupVctx(t, vctxConfig, map[string]shared.Token{ "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"}, }) // Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values. baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient"} args := []string{"kv", "get", "-field=foo", "secret/x"} bin, argv, env, err := buildInvocation("sydney", args, baseEnv) if err != nil { t.Fatalf("buildInvocation: %v", err) } if bin != vault { t.Errorf("bin = %q, want %q", bin, vault) } // argv is vault + the untouched args (vault flags like -field pass through). wantArgv := append([]string{vault}, args...) if !reflect.DeepEqual(argv, wantArgv) { t.Errorf("argv = %v, want %v", argv, wantArgv) } if v, _ := lastVal(env, "VAULT_ADDR"); v != "https://vault.syd1.au.unkin.net" { t.Errorf("VAULT_ADDR = %q, want context override to win", v) } if v, _ := lastVal(env, "VAULT_TOKEN"); v != "s.SYDTOK" { t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v) } if _, ok := lastVal(env, "VAULT_NAMESPACE"); ok { t.Error("VAULT_NAMESPACE should be unset for a context with no namespace") } // The ambient HOME must be preserved. if v, _ := lastVal(env, "HOME"); v != "/home/x" { t.Errorf("HOME = %q, base env not preserved", v) } } func TestBuildInvocationNamespaceFromContext(t *testing.T) { setupVctx(t, vctxConfig, map[string]shared.Token{ "staging/sydney": {Address: "https://vault-staging.syd1.au.unkin.net", Namespace: "staging", Token: "s.X"}, }) _, _, env, err := buildInvocation("staging/sydney", []string{"token", "lookup"}, nil) if err != nil { t.Fatalf("buildInvocation: %v", err) } if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "staging" { t.Errorf("VAULT_NAMESPACE = %q, want staging", v) } } func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) { // Context has no namespace, but the cached token records one — vctx should // fall back to the token's namespace. setupVctx(t, vctxConfig, map[string]shared.Token{ "sydney": {Address: "https://vault.syd1.au.unkin.net", Namespace: "from-token", Token: "s.X"}, }) _, _, env, err := buildInvocation("sydney", []string{"status"}, nil) if err != nil { t.Fatalf("buildInvocation: %v", err) } if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "from-token" { t.Errorf("VAULT_NAMESPACE = %q, want from-token fallback", v) } } func TestBuildInvocationMissingToken(t *testing.T) { setupVctx(t, vctxConfig, nil) // no cached tokens _, _, _, err := buildInvocation("sydney", []string{"status"}, nil) if err == nil { t.Fatal("expected error for missing cached token") } if !errors.Is(err, os.ErrNotExist) { t.Errorf("error should wrap os.ErrNotExist, got %v", err) } } func TestBuildInvocationUnknownContext(t *testing.T) { setupVctx(t, vctxConfig, nil) if _, _, _, err := buildInvocation("nope", []string{"status"}, nil); err == nil { t.Error("expected error for unknown context") } } func TestRunVctxValidation(t *testing.T) { if err := runVctx("", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") { t.Errorf("empty context: got %v", err) } if err := runVctx("sydney", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") { t.Errorf("no args: got %v", err) } } func TestRunVctxExecsWithBuiltInvocation(t *testing.T) { vault := setupVctx(t, vctxConfig, map[string]shared.Token{ "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"}, }) var gotBin string var gotArgv, gotEnv []string orig := execVault execVault = func(bin string, argv, env []string) error { gotBin, gotArgv, gotEnv = bin, argv, env return nil } t.Cleanup(func() { execVault = orig }) if err := runVctx("sydney", []string{"kv", "list", "kv/"}); err != nil { t.Fatalf("runVctx: %v", err) } if gotBin != vault { t.Errorf("exec bin = %q, want %q", gotBin, vault) } if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) { t.Errorf("exec argv = %v", gotArgv) } if v, _ := lastVal(gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" { t.Errorf("exec env VAULT_TOKEN = %q", v) } } func TestRunVctxSurfacesExecError(t *testing.T) { setupVctx(t, vctxConfig, map[string]shared.Token{ "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"}, }) orig := execVault execVault = func(bin string, argv, env []string) error { return errors.New("boom") } t.Cleanup(func() { execVault = orig }) err := runVctx("sydney", []string{"status"}) if err == nil || !strings.Contains(err.Error(), "exec vault") { t.Errorf("expected wrapped exec error, got %v", err) } }