Files
pad/internal/cli/env_token_test.go
David Barkhausen 5784d907c0 feat(cli): PAD_TOKEN environment override for stored credentials (#879) (#1160)
* feat(cli): PAD_TOKEN environment override for stored credentials (#879)

Layer 1 of #879: if PAD_TOKEN is set, the CLI uses it as the bearer
token and skips the credential-store lookup — gh's GH_TOKEN convention.
Reads never write credentials.json, so a read-only override sidesteps
the multi-agent identity contention completely; the store is never
touched under the override.

Per the acceptance grounding notes:

- NewClientFromURL resolves PAD_TOKEN before the per-server store
  lookup (the single token-attachment chokepoint).
- whoami no longer lies under the override: it skips the store
  short-circuit and reports the effective identity via a real /me
  fetch, with an 'Auth: PAD_TOKEN environment override' line.
- auth login/logout print a gh-style stderr notice when the override
  is active. logout additionally pins its server-side session
  invalidation to the STORED token — an unpinned Logout() after the
  constructor change would have invalidated the env token's session —
  and skips the server call when there is no stored session.
- pad init's status line and server info's report disclose the
  override (env_token_override field; the auth probe uses the token
  every other command would use).

Zero behaviour change when PAD_TOKEN is unset. Token minting stays
web-only; a minimal 'pad token' CLI is offered as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): review round 1 — init fails on a rejected PAD_TOKEN; login shortcut skipped under the override; logout asymmetry documented

Per the PR #1160 round-1 review:

- Bug 1: pad init's auth step no longer falls back to stored
  credentials when a set PAD_TOKEN is rejected — it fails with the
  distinct rejected-token message (mirroring whoami), which also makes
  the status line's override disclosure truthful. Test drives the real
  padInitCmd flow and asserts the stored identity is never consulted.
- Bug 2: login's 'Already logged in as <stored user>' shortcut is
  skipped when the override is active — it reads the store, and firing
  it right after envTokenNotice contradicted the notice. A second test
  pins the unchanged no-override shortcut behaviour.
- Doc ask: the deliberate logout asymmetry (the env token's own
  session is never invalidated; its lifecycle belongs to the minter,
  GH_TOKEN posture) is now stated in env_token.go's doc comment and
  the README PAD_TOKEN section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 07:54:13 -04:00

107 lines
3.2 KiB
Go

package cli
import (
"os"
"path/filepath"
"testing"
)
// Tests for the PAD_TOKEN environment override (issue #879, layer 1).
//
// Environment isolation sets BOTH HOME and USERPROFILE: CredentialsPath
// resolves through os.UserHomeDir, which reads HOME on Unix and
// USERPROFILE on Windows — setting only HOME (the older convention in
// this package) leaves Windows runs pointed at the developer's real
// ~/.pad.
func setTempHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
}
// PAD_TOKEN is an explicit per-process override, so it must win exactly
// when set to something non-blank and be invisible otherwise.
func TestEnvToken_SetReturnsTrimmed(t *testing.T) {
t.Setenv("PAD_TOKEN", " pad_abc123 ")
if got := EnvToken(); got != "pad_abc123" {
t.Errorf("EnvToken() = %q, want %q", got, "pad_abc123")
}
}
func TestEnvToken_UnsetReturnsEmpty(t *testing.T) {
t.Setenv("PAD_TOKEN", "")
if got := EnvToken(); got != "" {
t.Errorf("EnvToken() = %q, want empty", got)
}
}
func TestEnvToken_WhitespaceOnlyReturnsEmpty(t *testing.T) {
t.Setenv("PAD_TOKEN", " ")
if got := EnvToken(); got != "" {
t.Errorf("EnvToken() = %q, want empty", got)
}
}
func writeStoreForClientTest(t *testing.T) {
t.Helper()
home := setTempHome(t)
dir := filepath.Join(home, ".pad")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := `{"version": 2, "credentials": {"http://127.0.0.1:7777": {
"server_url": "http://127.0.0.1:7777", "token": "padsess_fromstore",
"user_id": "u-1", "email": "a@b.c", "name": "A"}}}`
if err := os.WriteFile(filepath.Join(dir, "credentials.json"), []byte(body), 0600); err != nil {
t.Fatalf("write: %v", err)
}
}
// PAD_TOKEN must beat the stored credential: the env var is the caller's
// explicit, per-process choice; the file is ambient machine state.
func TestNewClientFromURL_EnvTokenOverridesStore(t *testing.T) {
writeStoreForClientTest(t)
t.Setenv("PAD_TOKEN", "pad_envtoken")
c := NewClientFromURL("http://127.0.0.1:7777")
if c.authToken != "pad_envtoken" {
t.Errorf("authToken = %q, want env token to win over store", c.authToken)
}
}
func TestNewClientFromURL_NoEnvFallsBackToStore(t *testing.T) {
writeStoreForClientTest(t)
t.Setenv("PAD_TOKEN", "")
c := NewClientFromURL("http://127.0.0.1:7777")
if c.authToken != "padsess_fromstore" {
t.Errorf("authToken = %q, want stored token when PAD_TOKEN unset", c.authToken)
}
}
// The override must not touch the store: reads stay side-effect-free
// (the invariant the v2 store documents), so a process running under
// PAD_TOKEN never contends over credentials.json.
func TestNewClientFromURL_EnvTokenLeavesStoreUntouched(t *testing.T) {
writeStoreForClientTest(t)
t.Setenv("PAD_TOKEN", "pad_envtoken")
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".pad", "credentials.json")
before, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read before: %v", err)
}
_ = NewClientFromURL("http://127.0.0.1:7777")
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read after: %v", err)
}
if string(before) != string(after) {
t.Error("credentials.json changed during client construction under PAD_TOKEN")
}
}