package main import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strings" "testing" "time" "git.unkin.net/unkin/vault-tools/shared" ) // setupVctl points XDG dirs at temp locations and writes a config file. It // returns the config dir base so tests can inspect cache writes. func setupVctl(t *testing.T, cfg 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) } } const vctlConfig = ` defaults: method: ldap user: ben contexts: sydney: address: https://vault.syd1.au.unkin.net staging/sydney: address: https://vault-staging.syd1.au.unkin.net namespace: staging ` // --- runOverContexts: selection + iteration + aggregation ------------------ func TestRunOverContextsSingle(t *testing.T) { setupVctl(t, vctlConfig) var seen []string fn := func(rc shared.ResolvedContext) (*shared.Token, error) { seen = append(seen, rc.Name) return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil } if err := runOverContexts([]string{"sydney"}, false, "", "", "login", fn); err != nil { t.Fatalf("runOverContexts: %v", err) } if len(seen) != 1 || seen[0] != "sydney" { t.Errorf("fn called for %v, want [sydney]", seen) } // Token must have been persisted to the cache. if _, err := shared.LoadToken("sydney"); err != nil { t.Errorf("token not saved: %v", err) } } func TestRunOverContextsAllIteratesEveryContext(t *testing.T) { setupVctl(t, vctlConfig) var seen []string fn := func(rc shared.ResolvedContext) (*shared.Token, error) { seen = append(seen, rc.Name) return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil } if err := runOverContexts(nil, true, "", "", "login", fn); err != nil { t.Fatalf("runOverContexts --all: %v", err) } sort.Strings(seen) want := []string{"staging/sydney", "sydney"} if strings.Join(seen, ",") != strings.Join(want, ",") { t.Errorf("fn called for %v, want %v", seen, want) } for _, n := range want { if _, err := shared.LoadToken(n); err != nil { t.Errorf("token for %q not saved: %v", n, err) } } } func TestRunOverContextsContextAndAllConflict(t *testing.T) { setupVctl(t, vctlConfig) fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } err := runOverContexts([]string{"sydney"}, true, "", "", "login", fn) if err == nil || !strings.Contains(err.Error(), "not both") { t.Errorf("expected conflict error, got %v", err) } } func TestRunOverContextsNoTarget(t *testing.T) { setupVctl(t, vctlConfig) fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } if err := runOverContexts(nil, false, "", "", "login", fn); err == nil { t.Error("expected error when neither context nor --all given") } } func TestRunOverContextsAllEmptyConfig(t *testing.T) { setupVctl(t, "contexts: {}\n") fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } err := runOverContexts(nil, true, "", "", "login", fn) if err == nil || !strings.Contains(err.Error(), "no contexts configured") { t.Errorf("expected no-contexts error, got %v", err) } } func TestRunOverContextsAggregatesFailures(t *testing.T) { setupVctl(t, vctlConfig) fn := func(rc shared.ResolvedContext) (*shared.Token, error) { if rc.Name == "sydney" { return nil, io.ErrUnexpectedEOF // simulate a login failure } return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil } err := runOverContexts(nil, true, "", "", "login", fn) if err == nil || !strings.Contains(err.Error(), "1 of 2") { t.Errorf("expected '1 of 2' aggregate error, got %v", err) } // The context that succeeded must still have been saved. if _, err := shared.LoadToken("staging/sydney"); err != nil { t.Errorf("successful context not saved despite sibling failure: %v", err) } // The failed one must not have a token. if _, err := shared.LoadToken("sydney"); err == nil { t.Error("failed context should not have a saved token") } } func TestRunOverContextsResolveErrorCounts(t *testing.T) { // A context missing an address fails resolution and is counted as a failure. setupVctl(t, ` contexts: good: address: https://vault.example.net bad: {} `) fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil } err := runOverContexts(nil, true, "", "", "login", fn) if err == nil || !strings.Contains(err.Error(), "1 of 2") { t.Errorf("expected resolve failure counted, got %v", err) } } func TestRunOverContextsAppliesOverrides(t *testing.T) { setupVctl(t, vctlConfig) var got shared.ResolvedContext fn := func(rc shared.ResolvedContext) (*shared.Token, error) { got = rc return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil } if err := runOverContexts([]string{"sydney"}, false, "okta", "someone", "login", fn); err != nil { t.Fatal(err) } if got.Method != "okta" || got.User != "someone" || got.Path != "okta" { t.Errorf("overrides not applied: %+v", got) } } // --- doLogin: method branching + prompt injection -------------------------- func TestDoLoginPasswordMethod(t *testing.T) { var gotPath string var gotBody = map[string]string{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &gotBody) _, _ = io.WriteString(w, `{"auth":{"client_token":"s.new","accessor":"acc","token_policies":["default"],"lease_duration":3600,"renewable":true}}`) })) defer srv.Close() var prompted string orig := promptSecret promptSecret = func(prompt string) (string, error) { prompted = prompt; return "hunter2", nil } t.Cleanup(func() { promptSecret = orig }) rc := shared.ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben"} tok, err := doLogin(rc) if err != nil { t.Fatalf("doLogin: %v", err) } if gotPath != "/v1/auth/ldap/login/ben" { t.Errorf("login path = %q", gotPath) } if gotBody["password"] != "hunter2" { t.Errorf("password not sent from prompt: %v", gotBody) } if !strings.Contains(prompted, "Password") { t.Errorf("password method should prompt for a password, got %q", prompted) } if tok.Token != "s.new" { t.Errorf("token = %q", tok.Token) } } func TestDoLoginTokenMethod(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("token method should call lookup-self, got %q", r.URL.Path) } if r.Header.Get("X-Vault-Token") != "s.pasted" { t.Errorf("token header = %q", r.Header.Get("X-Vault-Token")) } _, _ = io.WriteString(w, `{"data":{"accessor":"acc","policies":["root"],"ttl":0,"renewable":false}}`) })) defer srv.Close() var prompted string orig := promptSecret promptSecret = func(prompt string) (string, error) { prompted = prompt; return "s.pasted", nil } t.Cleanup(func() { promptSecret = orig }) rc := shared.ResolvedContext{Name: "root-ctx", Address: srv.URL, Method: "token"} tok, err := doLogin(rc) if err != nil { t.Fatalf("doLogin(token): %v", err) } if !strings.Contains(prompted, "token") { t.Errorf("token method should prompt for a token, got %q", prompted) } if tok.Token != "s.pasted" || tok.Accessor != "acc" { t.Errorf("token = %+v", tok) } } // --- doRenew --------------------------------------------------------------- func TestDoRenewNoCachedToken(t *testing.T) { setupVctl(t, vctlConfig) rc := shared.ResolvedContext{Name: "sydney", Address: "https://vault.example.net"} if _, err := doRenew(rc); err == nil { t.Error("expected error renewing a context with no cached token") } } func TestDoRenewUsesCachedToken(t *testing.T) { setupVctl(t, vctlConfig) 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) } if r.Header.Get("X-Vault-Token") != "s.cached" { t.Errorf("renew must use cached token, got %q", r.Header.Get("X-Vault-Token")) } _, _ = io.WriteString(w, `{"auth":{"client_token":"s.cached","lease_duration":7200,"renewable":true}}`) })) defer srv.Close() if err := shared.SaveToken(&shared.Token{Context: "sydney", Address: srv.URL, Token: "s.cached", Accessor: "acc-old"}); err != nil { t.Fatal(err) } rc := shared.ResolvedContext{Name: "sydney", Address: srv.URL} tok, err := doRenew(rc) if err != nil { t.Fatalf("doRenew: %v", err) } if tok.LeaseDurationSeconds != 7200 { t.Errorf("lease = %d, want 7200", tok.LeaseDurationSeconds) } if tok.Accessor != "acc-old" { t.Errorf("accessor should carry over, got %q", tok.Accessor) } } // --- runList --------------------------------------------------------------- func TestRunListShowsTokenStatus(t *testing.T) { setupVctl(t, vctlConfig) // sydney has a valid token; staging/sydney has none. if err := shared.SaveToken(&shared.Token{ Context: "sydney", Address: "https://vault.syd1.au.unkin.net", Token: "s.x", ExpiresAt: time.Now().Add(30 * time.Minute), }); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := runList(&buf); err != nil { t.Fatalf("runList: %v", err) } out := buf.String() if !strings.Contains(out, "sydney") || !strings.Contains(out, "token valid, expires in") { t.Errorf("expected valid-token status for sydney:\n%s", out) } if !strings.Contains(out, "staging/sydney") || !strings.Contains(out, "no token") { t.Errorf("expected 'no token' for staging/sydney:\n%s", out) } } func TestRunListEmptyConfig(t *testing.T) { setupVctl(t, "contexts: {}\n") var buf bytes.Buffer if err := runList(&buf); err != nil { t.Fatal(err) } if !strings.Contains(buf.String(), "no contexts configured") { t.Errorf("expected no-contexts message, got %q", buf.String()) } } // --- pure helpers ---------------------------------------------------------- func TestShort(t *testing.T) { if got := short("abcdefghij"); got != "abcdefgh..." { t.Errorf("short(long) = %q", got) } if got := short("abc"); got != "abc" { t.Errorf("short(short) = %q", got) } } func TestTokenSummary(t *testing.T) { s := tokenSummary(&shared.Token{ Accessor: "accessor-123456", LeaseDurationSeconds: 3600, Policies: []string{"default", "kv"}, Renewable: true, }) for _, want := range []string{"accessor=accessor", "ttl=1h0m0s", "policies=default,kv", "renewable=true"} { if !strings.Contains(s, want) { t.Errorf("summary %q missing %q", s, want) } } }