1823eb9348
Adds explicit cmd-level validation tests for workspace migrate-provider: - --confirm required - --from and --to cannot be the same provider - --from-instance-id required for non-AWS sources - valid provider passes guard and fails later on missing CP env (post-guard) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
343 lines
11 KiB
Go
343 lines
11 KiB
Go
package cmd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// TestBudgetFlagMapping verifies the budget flag→limits translation:
|
|
// unset flags are omitted, negative clears (nil), non-negative sets a pointer.
|
|
// Exercises the SHARED budgetLimitsFromFlags helper so prod and test agree.
|
|
func TestBudgetFlagMapping(t *testing.T) {
|
|
// All unset → empty map (show, not set).
|
|
if got := budgetLimitsFromFlags(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 {
|
|
t.Errorf("all-unset: want empty map, got %v", got)
|
|
}
|
|
// monthly=50000 set, daily=-1 clear, others unset.
|
|
got := budgetLimitsFromFlags(budgetUnset, -1, budgetUnset, 50000)
|
|
if len(got) != 2 {
|
|
t.Fatalf("want 2 entries, got %d (%v)", len(got), got)
|
|
}
|
|
if v, ok := got["daily"]; !ok || v != nil {
|
|
t.Errorf("daily: want present+nil (clear), got ok=%v v=%v", ok, v)
|
|
}
|
|
if v, ok := got["monthly"]; !ok || v == nil || *v != 50000 {
|
|
t.Errorf("monthly: want 50000, got ok=%v v=%v", ok, v)
|
|
}
|
|
if _, ok := got["hourly"]; ok {
|
|
t.Errorf("hourly should be absent when unset")
|
|
}
|
|
}
|
|
|
|
// TestBillingModeValidation walks the billing-mode arg validation branches via
|
|
// the SHARED resolveBillingMode helper (same code prod runs).
|
|
func TestBillingModeValidation(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantMode string // resolved mode passed to the client
|
|
wantErr bool
|
|
}{
|
|
{"platform_managed", "platform_managed", false},
|
|
{"byok", "byok", false},
|
|
{"disabled", "disabled", false},
|
|
{"clear", "", false},
|
|
{"null", "", false},
|
|
{"", "", false},
|
|
{"bogus", "", true},
|
|
}
|
|
for _, tc := range cases {
|
|
mode, err := resolveBillingMode(tc.in)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr)
|
|
}
|
|
if !tc.wantErr && mode != tc.wantMode {
|
|
t.Errorf("%q: resolved mode=%q want %q", tc.in, mode, tc.wantMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReadFileMappings verifies template --file relpath=localpath parsing +
|
|
// file reads, including the error branches.
|
|
func TestReadFileMappings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
good := filepath.Join(dir, "org.yaml")
|
|
if err := os.WriteFile(good, []byte("name: x\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Valid mapping.
|
|
out, err := readFileMappings([]string{"org.yaml=" + good})
|
|
if err != nil {
|
|
t.Fatalf("valid mapping: %v", err)
|
|
}
|
|
if out["org.yaml"] != "name: x\n" {
|
|
t.Errorf("contents = %q", out["org.yaml"])
|
|
}
|
|
|
|
// Missing '='.
|
|
if _, err := readFileMappings([]string{"justakey"}); err == nil {
|
|
t.Errorf("missing '=' should error")
|
|
}
|
|
// Empty relpath.
|
|
if _, err := readFileMappings([]string{"=" + good}); err == nil {
|
|
t.Errorf("empty relpath should error")
|
|
}
|
|
// Nonexistent local file.
|
|
if _, err := readFileMappings([]string{"a.yaml=" + filepath.Join(dir, "nope")}); err == nil {
|
|
t.Errorf("nonexistent local file should error")
|
|
}
|
|
}
|
|
|
|
// TestJSONFlagResolution confirms --json sets outputFormat=json via the
|
|
// persistent pre-run hook.
|
|
func TestJSONFlagResolution(t *testing.T) {
|
|
origFmt, origJSON := outputFormat, jsonOutput
|
|
defer func() { outputFormat, jsonOutput = origFmt, origJSON }()
|
|
|
|
outputFormat = "table"
|
|
jsonOutput = true
|
|
rootCmd.PersistentPreRun(rootCmd, nil)
|
|
if outputFormat != "json" {
|
|
t.Errorf("--json should set outputFormat=json, got %q", outputFormat)
|
|
}
|
|
}
|
|
|
|
// TestAuthHelpers confirms the credential loaders read the documented env vars.
|
|
func TestAuthHelpers(t *testing.T) {
|
|
t.Setenv("MOLECULE_API_KEY", "k1")
|
|
t.Setenv("MOLECULE_ORG_ID", "o1")
|
|
if authToken() != "k1" {
|
|
t.Errorf("authToken() = %q, want k1", authToken())
|
|
}
|
|
if orgID() != "o1" {
|
|
t.Errorf("orgID() = %q, want o1", orgID())
|
|
}
|
|
}
|
|
|
|
// TestApplyConfigDefaults confirms a config-file api_url/output value reaches
|
|
// the flag-backed globals (so `config set api_url` actually affects newClient),
|
|
// while an env var or an already-overridden global wins over the config file.
|
|
func TestApplyConfigDefaults(t *testing.T) {
|
|
origAPI, origFmt := apiURL, outputFormat
|
|
defer func() {
|
|
apiURL, outputFormat = origAPI, origFmt
|
|
viper.Reset()
|
|
}()
|
|
|
|
// 1) Config file provides api_url + output; no env, globals at default →
|
|
// config values are adopted.
|
|
viper.Reset()
|
|
viper.Set("api_url", "https://cfg.example")
|
|
viper.Set("output", "json")
|
|
t.Setenv("MOLECULE_API_URL", "")
|
|
t.Setenv("MOL_OUTPUT", "")
|
|
apiURL = "http://localhost:8080" // untouched flag default
|
|
outputFormat = "table" // untouched flag default
|
|
applyConfigDefaults()
|
|
if apiURL != "https://cfg.example" {
|
|
t.Errorf("apiURL = %q, want config value (config set api_url must flow to the client)", apiURL)
|
|
}
|
|
if outputFormat != "json" {
|
|
t.Errorf("outputFormat = %q, want config value", outputFormat)
|
|
}
|
|
|
|
// 2) Env var present → env wins, config file ignored.
|
|
viper.Reset()
|
|
viper.Set("api_url", "https://cfg.example")
|
|
t.Setenv("MOLECULE_API_URL", "https://env.example")
|
|
apiURL = "https://env.example" // flag default already folded the env in
|
|
applyConfigDefaults()
|
|
if apiURL != "https://env.example" {
|
|
t.Errorf("apiURL = %q, want env value (env must win over config file)", apiURL)
|
|
}
|
|
|
|
// 3) Global already overridden away from the default (e.g. explicit flag)
|
|
// → config file does not clobber it.
|
|
viper.Reset()
|
|
viper.Set("api_url", "https://cfg.example")
|
|
t.Setenv("MOLECULE_API_URL", "")
|
|
apiURL = "https://flag.example"
|
|
applyConfigDefaults()
|
|
if apiURL != "https://flag.example" {
|
|
t.Errorf("apiURL = %q, want flag value (explicit flag must win over config file)", apiURL)
|
|
}
|
|
}
|
|
|
|
// TestConfigSetMkdirsConfigDir confirms `config set` creates a missing config
|
|
// dir before writing (SafeWriteConfig/WriteConfig fail on a nonexistent dir).
|
|
func TestConfigSetMkdirsConfigDir(t *testing.T) {
|
|
// Point os.UserConfigDir at a temp HOME whose ~/.config does NOT exist yet.
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp) // darwin/linux
|
|
t.Setenv("XDG_CONFIG_HOME", "") // force ~/.config derivation on linux
|
|
t.Setenv("AppData", filepath.Join(tmp, "AppData", "Roaming")) // windows
|
|
|
|
if err := runConfigSet(nil, []string{"api_url", "https://written.example"}); err != nil {
|
|
t.Fatalf("runConfigSet on missing config dir: %v", err)
|
|
}
|
|
cd, _ := os.UserConfigDir()
|
|
if _, err := os.Stat(filepath.Join(cd, "molecule.yaml")); err != nil {
|
|
t.Errorf("config file not written: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCPURLNoTenantFallback confirms cpURL does NOT fall back to apiURL when
|
|
// MOLECULE_CP_URL is unset (it returns "" so the admin client can refuse to
|
|
// send the CP-admin bearer to a tenant host), and returns MOLECULE_CP_URL when
|
|
// set.
|
|
func TestCPURLNoTenantFallback(t *testing.T) {
|
|
origAPI := apiURL
|
|
defer func() { apiURL = origAPI }()
|
|
apiURL = "https://tenant.example"
|
|
|
|
t.Setenv("MOLECULE_CP_URL", "")
|
|
if got := cpURL(); got != "" {
|
|
t.Errorf("cpURL with MOLECULE_CP_URL unset = %q, want \"\" (no tenant fallback)", got)
|
|
}
|
|
t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app")
|
|
if got := cpURL(); got != "https://api.moleculesai.app" {
|
|
t.Errorf("cpURL = %q, want CP url", got)
|
|
}
|
|
}
|
|
|
|
// TestCPAdminClientCredentialTargeting confirms the CP-admin client never sends
|
|
// the CP-admin bearer to the tenant apiURL: it requires an explicit
|
|
// MOLECULE_CP_URL and fails fast otherwise, even when MOLECULE_CP_ADMIN_TOKEN
|
|
// is set.
|
|
func TestCPAdminClientCredentialTargeting(t *testing.T) {
|
|
origAPI := apiURL
|
|
defer func() { apiURL = origAPI }()
|
|
apiURL = "https://tenant.example"
|
|
|
|
// Token present but no CP URL → must fail fast (NOT target the tenant).
|
|
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-secret")
|
|
t.Setenv("MOLECULE_CP_URL", "")
|
|
if _, err := cpAdminClient(); err == nil {
|
|
t.Fatal("cpAdminClient with no MOLECULE_CP_URL should fail fast, not target the tenant host")
|
|
}
|
|
|
|
// Token + explicit CP URL → client points at the CP, never the tenant.
|
|
t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app")
|
|
cp, err := cpAdminClient()
|
|
if err != nil {
|
|
t.Fatalf("cpAdminClient with CP URL set: %v", err)
|
|
}
|
|
if cp.BaseURL != "https://api.moleculesai.app" {
|
|
t.Errorf("cpAdminClient BaseURL = %q, want CP url (must not be the tenant %q)", cp.BaseURL, apiURL)
|
|
}
|
|
if cp.BaseURL == apiURL {
|
|
t.Errorf("cpAdminClient targeted the tenant apiURL %q — CP-admin bearer would leak", apiURL)
|
|
}
|
|
// Missing token → fail fast regardless of CP URL.
|
|
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "")
|
|
if _, err := cpAdminClient(); err == nil {
|
|
t.Fatal("cpAdminClient with no MOLECULE_CP_ADMIN_TOKEN should fail fast")
|
|
}
|
|
}
|
|
|
|
// TestValidMigrationProvider walks the provider validation used by
|
|
// 'workspace migrate-provider' (shared helper, same code prod runs).
|
|
func TestValidMigrationProvider(t *testing.T) {
|
|
for _, p := range []string{"aws", "hetzner", "gcp"} {
|
|
if !validMigrationProvider(p) {
|
|
t.Errorf("%q should be a valid provider", p)
|
|
}
|
|
}
|
|
for _, p := range []string{"", "azure", "AWS", "do", "linode"} {
|
|
if validMigrationProvider(p) {
|
|
t.Errorf("%q should NOT be a valid provider", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunWorkspaceMigrateProviderValidation covers the client-side guards in
|
|
// runWorkspaceMigrateProvider: --confirm required, --from-instance-id required
|
|
// for non-AWS sources, and --from != --to. These fail fast before any CP call.
|
|
func TestRunWorkspaceMigrateProviderValidation(t *testing.T) {
|
|
orig := migrateFlags
|
|
defer func() { migrateFlags = orig }()
|
|
|
|
resetFlags := func() {
|
|
migrateFlags.to = ""
|
|
migrateFlags.from = ""
|
|
migrateFlags.fromInstanceID = ""
|
|
migrateFlags.orgID = ""
|
|
migrateFlags.runtime = ""
|
|
migrateFlags.confirm = false
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
setup func()
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "confirm required",
|
|
setup: func() {
|
|
resetFlags()
|
|
migrateFlags.to = "hetzner"
|
|
migrateFlags.from = "aws"
|
|
},
|
|
wantErr: "refusing to migrate without --confirm",
|
|
},
|
|
{
|
|
name: "from==to rejected",
|
|
setup: func() {
|
|
resetFlags()
|
|
migrateFlags.to = "aws"
|
|
migrateFlags.from = "aws"
|
|
migrateFlags.confirm = true
|
|
},
|
|
wantErr: "--from and --to are the same provider",
|
|
},
|
|
{
|
|
name: "non-aws source requires from-instance-id",
|
|
setup: func() {
|
|
resetFlags()
|
|
migrateFlags.to = "aws"
|
|
migrateFlags.from = "gcp"
|
|
migrateFlags.confirm = true
|
|
},
|
|
wantErr: "--from-instance-id is required for a non-AWS",
|
|
},
|
|
{
|
|
name: "hetzner source with instance id passes guard",
|
|
setup: func() {
|
|
resetFlags()
|
|
migrateFlags.to = "aws"
|
|
migrateFlags.from = "hetzner"
|
|
migrateFlags.confirm = true
|
|
migrateFlags.fromInstanceID = "hz-box-1"
|
|
},
|
|
wantErr: "", // fails later at cpAdminClient because env is not set; guard is what we test
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resetFlags()
|
|
tc.setup()
|
|
err := runWorkspaceMigrateProvider(nil, []string{"ws-123"})
|
|
if tc.wantErr == "" {
|
|
if err == nil {
|
|
t.Fatal("expected error after guard (cpAdminClient env missing), got nil")
|
|
}
|
|
if strings.Contains(err.Error(), "--confirm") || strings.Contains(err.Error(), "same provider") || strings.Contains(err.Error(), "--from-instance-id") {
|
|
t.Fatalf("expected post-guard error, got guard error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|