Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ twoctl --context cyber checkout get-order --order-id abc # kubectl-style alias
twoctl --url http://localhost:8080 --api-key secret_test_x ...
```

Scripts can select a context without changing the saved selection:

```sh
TWOCTL_CONTEXT=staging twoctl order get --order-id abc
```

Context selection follows `--context`, then `--env`, then non-empty
`TWOCTL_CONTEXT`, then the saved current context. `--url` overrides the
API URL and requires `--api-key` or `TWO_API_KEY` rather than using a stored
context key. API calls, `auth whoami`, `auth login`, `auth logout`, and
`--dry-run` honor the invocation context. `config current-context` continues
to show the saved selection; the environment override never writes it.

For built-in env names (`prod`, `sandbox`, `staging`, `cyber`, `perf`,
`release`) twoctl knows the URL. For any other name it falls back to
`https://api.<name>.two.inc`; override with `--url`.
Expand Down
8 changes: 6 additions & 2 deletions cmd/twoctl/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func init() {
Long: `Store, remove, and inspect the API key for a context.

By default, auth subcommands operate on the current context (set by
` + "`twoctl config use-context`" + `). Pass --context to target a different one.`,
` + "`twoctl config use-context`" + `). Pass --context or set TWOCTL_CONTEXT to target a different one.`,
}
authCmd.AddCommand(
authLoginCmd(),
Expand Down Expand Up @@ -101,8 +101,12 @@ func authWhoamiCmd() *cobra.Command {
}

// pickContext returns the explicit override if provided, otherwise the
// current context name from config. Errors if neither is set.
// invocation context (--context / --env / TWOCTL_CONTEXT), then the saved
// context name. Errors if none is set.
func pickContext(override string) (string, error) {
if override == "" {
override = activeEnv()
}
if override != "" {
return override, nil
}
Expand Down
151 changes: 151 additions & 0 deletions cmd/twoctl/cli/context_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package cli

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/two-inc/twoctl-cli/internal/config"
)

func TestContextEnvironmentPrecedence(t *testing.T) {
cases := []struct {
name string
env string
flags []string
want string
}{
{"saved", "", nil, "saved"},
{"environment", "job", nil, "job"},
{"env flag", "job", []string{"--env", "explicit"}, "explicit"},
{"context flag", "job", []string{"--context", "explicit"}, "explicit"},
{"both flags", "job", []string{"--env", "job", "--context", "explicit"}, "explicit"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resetGlobals(t)
t.Setenv("TWOCTL_CONTEXT", tc.env)
t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1")
var path, key string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path, key = r.URL.Path, r.Header.Get("X-Api-Key")
fmt.Fprint(w, `{"items":[]}`)
}))
defer srv.Close()
for _, name := range []string{"saved", "job", "explicit"} {
if err := config.SetContext(name, srv.URL+"/"+name, name+"-key"); err != nil {
t.Fatal(err)
}
}
args := append(append([]string{}, tc.flags...), "billing-statement", "get")
if _, _, err := runRoot(t, args...); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(path, "/"+tc.want+"/") || key != tc.want+"-key" {
t.Fatalf("selected path=%q key=%q, want context %s", path, key, tc.want)
}
assertSavedContext(t, "saved")
})
}
}

func assertSavedContext(t *testing.T, want string) {
t.Helper()
cfg, err := config.LoadFile()
if err != nil {
t.Fatal(err)
}
if cfg.CurrentContext != want {
t.Fatalf("saved context changed to %q, want %q", cfg.CurrentContext, want)
}
}

func TestContextEnvironmentDryRunWithoutKey(t *testing.T) {
resetGlobals(t)
t.Setenv("TWOCTL_CONTEXT", "job")
t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1")
if err := config.SetContext("job", "https://custom.example.test/api", ""); err != nil {
t.Fatal(err)
}
stdout, _, err := runRoot(t, "billing-statement", "get", "--dry-run")
if err != nil {
t.Fatal(err)
}
var request struct {
URL string `json:"url"`
Headers http.Header `json:"headers"`
}
if err := json.Unmarshal([]byte(stdout), &request); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(request.URL, "https://custom.example.test/api/") {
t.Fatalf("dry-run ignored the registered context URL: %s", request.URL)
}
if request.Headers.Get("X-Api-Key") != "" {
t.Fatal("dry-run injected a placeholder API key")
}
}

func TestContextEnvironmentRawURLRequiresExplicitKey(t *testing.T) {
resetGlobals(t)
t.Setenv("TWOCTL_CONTEXT", "job")
t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1")
if err := config.SetContext("job", "", "context-key"); err != nil {
t.Fatal(err)
}
_, _, err := runRoot(t, "--url", "https://override.example.test", "billing-statement", "get")
if err != config.ErrNoAPIKey {
t.Fatalf("raw URL must not use an environment context's key: %v", err)
}
}

func TestContextEnvironmentAuthCommands(t *testing.T) {
for _, command := range []string{"login", "logout", "whoami"} {
t.Run(command, func(t *testing.T) {
resetGlobals(t)
t.Setenv("TWOCTL_CONTEXT", "job")
t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1")
for _, name := range []string{"saved", "job"} {
if err := config.SetContext(name, "", "secret_test_abcdefghijklmnop"); err != nil {
t.Fatal(err)
}
}
args := []string{"auth", command}
if command == "login" {
args = append(args, "--key", "secret_test_zyxwvutsrqponmlk")
}
stdout, _, err := runRoot(t, args...)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout, "job") {
t.Fatalf("auth %s did not target environment context: %s", command, stdout)
}
if command == "logout" && config.HasStoredKey("job") {
t.Fatal("logout did not remove the environment context's key")
}
if command == "login" {
resolved, err := config.Resolve("", "job", "")
if err != nil || resolved.APIKey != "secret_test_zyxwvutsrqponmlk" {
t.Fatalf("login did not update the environment context's key: %v", err)
}
}
if !config.HasStoredKey("saved") {
t.Fatal("auth command affected saved context's key")
}
assertSavedContext(t, "saved")
})
}
}

func TestAuthExplicitContextOverridesEnvironment(t *testing.T) {
resetGlobals(t)
t.Setenv("TWOCTL_CONTEXT", "job")
got, err := pickContext("explicit")
if err != nil || got != "explicit" {
t.Fatalf("explicit context lost precedence: %q, %v", got, err)
}
}
8 changes: 7 additions & 1 deletion cmd/twoctl/cli/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,13 @@ func runOperation(c *cobra.Command, method, path string, op *openapi3.Operation,
}

func resolveForRun(dryRun bool) (*config.Resolved, error) {
resolved, err := config.Resolve(flagAPIKey, activeEnv(), flagURL)
key := flagAPIKey
if dryRun {
// Resolve the real context URL without reading a keychain entry.
// Dry-run output never uses this placeholder as an auth header.
key = "dry-run"
}
resolved, err := config.Resolve(key, activeEnv(), flagURL)
if err == nil {
return resolved, nil
}
Expand Down
1 change: 1 addition & 0 deletions cmd/twoctl/cli/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func resetGlobals(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
t.Setenv("TWO_API_KEY", "")
t.Setenv("TWOCTL_CONTEXT", "")
keyring.MockInit()
}

Expand Down
10 changes: 8 additions & 2 deletions cmd/twoctl/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package cli

import (
"os"

"github.com/spf13/cobra"

"github.com/two-inc/twoctl-cli/internal/httpx"
Expand All @@ -18,12 +20,16 @@ var (
)

// activeEnv returns the context-selecting flag value, preferring --context
// over --env when both are set (kubectl-style precedence).
// over --env when both are set (kubectl-style precedence), then falling
// back to TWOCTL_CONTEXT for scripts without changing the saved context.
func activeEnv() string {
if flagContext != "" {
return flagContext
}
return flagEnv
if flagEnv != "" {
return flagEnv
}
return os.Getenv("TWOCTL_CONTEXT")
}

var rootCmd = &cobra.Command{
Expand Down
Loading