diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 480f3dd..9c293b1 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -308,3 +308,95 @@ func TestCLIPath_NotFound(t *testing.T) { t.Errorf("CLIPath = %q, want empty", path) } } + +func TestProviderCreate_Args(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + bin := writeStub(t, `#!/bin/bash +printf '%s\n' "$*" > `+argsFile+` +`) + gw := NewCLI(bin) + gw.ProviderCreate("vertex-local", "google-vertex-ai", ProviderCreateOpts{ + FromADC: true, + Credentials: []string{"TOKEN=abc"}, + Configs: []string{"PROJECT=my-proj", "REGION=us-east5"}, + }) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + for _, want := range []string{ + "--name vertex-local", + "--type google-vertex-ai", + "--from-gcloud-adc", + "--credential TOKEN=abc", + "--config PROJECT=my-proj", + "--config REGION=us-east5", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } +} + +func TestInferenceSet_Args(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + bin := writeStub(t, `#!/bin/bash +printf '%s\n' "$*" > `+argsFile+` +`) + gw := NewCLI(bin) + gw.InferenceSet("vertex-local", "claude-sonnet-4-6") + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + for _, want := range []string{ + "inference set", + "--provider vertex-local", + "--model claude-sonnet-4-6", + "--no-verify", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } +} + +func TestGatewayAdd_Args(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + bin := writeStub(t, `#!/bin/bash +printf '%s\n' "$*" > `+argsFile+` +`) + gw := NewCLI(bin) + gw.GatewayAdd("https://gw.example.com:443", "my-ocp", true) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + for _, want := range []string{ + "gateway add", + "https://gw.example.com:443", + "--name my-ocp", + "--local", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } +} + +func TestGatewayRemove(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.GatewayRemove("old-gw"); err != nil { + t.Errorf("GatewayRemove: %v", err) + } +} + +func TestProviderProfileDelete(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.ProviderProfileDelete("profile-123"); err != nil { + t.Errorf("ProviderProfileDelete: %v", err) + } +} diff --git a/internal/k8s/kubectl_test.go b/internal/k8s/kubectl_test.go index 54d3b29..4383f2f 100644 --- a/internal/k8s/kubectl_test.go +++ b/internal/k8s/kubectl_test.go @@ -175,6 +175,109 @@ func TestIsTransient(t *testing.T) { } } +func TestRunKubectl_RetryExhausted(t *testing.T) { + dir := t.TempDir() + counterFile := filepath.Join(dir, "count") + os.WriteFile(counterFile, []byte("0"), 0o644) + + writeStub(t, `#!/bin/bash +COUNT=$(cat `+counterFile+`) +COUNT=$((COUNT + 1)) +echo $COUNT > `+counterFile+` +echo "connection refused" >&2 +exit 1 +`) + c := New("", "") + _, err := c.RunKubectl(context.Background(), "get", "pods") + if err == nil { + t.Error("expected error after retry exhaustion") + } + data, _ := os.ReadFile(counterFile) + if string(data) != "3\n" { + t.Errorf("expected 3 attempts, got %s", data) + } +} + +func TestRunKubectlQuiet_DiscardsOutput(t *testing.T) { + writeStub(t, `#!/bin/bash +echo "this should be discarded" +echo "error output" >&2 +`) + c := New("", "") + err := c.RunKubectlQuiet(context.Background(), "get", "pods") + if err != nil { + t.Errorf("RunKubectlQuiet: %v", err) + } +} + +func TestGetSecretField_Valid(t *testing.T) { + writeStub(t, `#!/bin/bash +# Return base64-encoded "hello" +echo -n "aGVsbG8=" +`) + c := New("", "default") + data, err := c.GetSecretField(context.Background(), "my-secret", "data-field") + if err != nil { + t.Fatalf("GetSecretField: %v", err) + } + if string(data) != "hello" { + t.Errorf("data = %q, want hello", string(data)) + } +} + +func TestGetSecretField_InvalidBase64(t *testing.T) { + writeStub(t, `#!/bin/bash +echo -n "not-valid-base64!!!" +`) + c := New("", "default") + _, err := c.GetSecretField(context.Background(), "my-secret", "field") + if err == nil { + t.Error("expected error for invalid base64") + } +} + +func TestGetSecretField_Empty(t *testing.T) { + writeStub(t, `#!/bin/bash +echo -n "" +`) + c := New("", "default") + data, err := c.GetSecretField(context.Background(), "my-secret", "field") + if err != nil { + t.Fatalf("GetSecretField: %v", err) + } + if len(data) != 0 { + t.Errorf("expected empty, got %q", data) + } +} + +func TestNamespaceExists(t *testing.T) { + writeStub(t, `#!/bin/bash +[[ "$*" == *"my-ns"* ]] && exit 0 +exit 1 +`) + c := New("", "") + if !c.NamespaceExists(context.Background(), "my-ns") { + t.Error("expected namespace to exist") + } + if c.NamespaceExists(context.Background(), "missing-ns") { + t.Error("expected namespace to not exist") + } +} + +func TestDefaultNamespace(t *testing.T) { + t.Setenv("OPENSHELL_NAMESPACE", "custom-ns") + if ns := DefaultNamespace(); ns != "custom-ns" { + t.Errorf("DefaultNamespace = %q, want custom-ns", ns) + } +} + +func TestDefaultNamespace_Default(t *testing.T) { + t.Setenv("OPENSHELL_NAMESPACE", "") + if ns := DefaultNamespace(); ns != "openshell" { + t.Errorf("DefaultNamespace = %q, want openshell", ns) + } +} + func contains(s, substr string) bool { return len(s) > 0 && len(substr) > 0 && s != substr && indexOf(s, substr) >= 0 } diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go new file mode 100644 index 0000000..adadb67 --- /dev/null +++ b/internal/preflight/preflight_test.go @@ -0,0 +1,311 @@ +package preflight + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadProviders_Valid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "providers.toml") + os.WriteFile(path, []byte(` +[[providers]] +name = "github" +type = "openshell" +description = "GitHub" +required = true +inputs = [ + { key = "GITHUB_TOKEN", kind = "env", secret = true }, +] + +[[providers]] +name = "gws" +type = "custom" +description = "Google Workspace" +upstream = "https://github.com/NVIDIA/OpenShell/issues/1268" +inputs = [] +`), 0o644) + + providers, err := LoadProviders(path) + if err != nil { + t.Fatalf("LoadProviders: %v", err) + } + if len(providers) != 2 { + t.Fatalf("got %d providers, want 2", len(providers)) + } + if providers[0].Name != "github" || providers[0].Type != "openshell" { + t.Errorf("providers[0] = %+v", providers[0]) + } + if !providers[0].Required { + t.Error("github should be required") + } + if len(providers[0].Inputs) != 1 || providers[0].Inputs[0].Kind != "env" { + t.Errorf("github inputs = %+v", providers[0].Inputs) + } + if providers[1].Upstream != "https://github.com/NVIDIA/OpenShell/issues/1268" { + t.Errorf("gws upstream = %q", providers[1].Upstream) + } +} + +func TestLoadProviders_Missing(t *testing.T) { + _, err := LoadProviders("/nonexistent.toml") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestLoadProviders_Invalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.toml") + os.WriteFile(path, []byte(`not valid {{{{`), 0o644) + + _, err := LoadProviders(path) + if err == nil { + t.Error("expected error for invalid TOML") + } +} + +func TestLoadConfig_Valid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "openshell.toml") + os.WriteFile(path, []byte(` +providers = ["github", "vertex-local"] +providers-custom = ["gws"] + +[upstream] +chart-version = "0.0.55" +`), 0o644) + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg == nil { + t.Fatal("config is nil") + } + if len(cfg.Providers) != 2 { + t.Errorf("providers = %v", cfg.Providers) + } + if len(cfg.ProvidersCustom) != 1 || cfg.ProvidersCustom[0] != "gws" { + t.Errorf("providers-custom = %v", cfg.ProvidersCustom) + } + if cfg.ChartVersion != "0.0.55" { + t.Errorf("ChartVersion = %q", cfg.ChartVersion) + } +} + +func TestLoadConfig_Missing(t *testing.T) { + cfg, err := LoadConfig("/nonexistent.toml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Error("expected nil for missing config") + } +} + +func TestEnabledProviders_WithConfig(t *testing.T) { + all := []Provider{ + {Name: "github", Type: "openshell"}, + {Name: "vertex", Type: "openshell"}, + {Name: "gws", Type: "custom"}, + } + cfg := &ConfigFile{ + Providers: []string{"github"}, + ProvidersCustom: []string{"gws"}, + } + enabled := EnabledProviders(all, cfg) + if len(enabled) != 2 { + t.Fatalf("got %d, want 2", len(enabled)) + } + if enabled[0].Name != "github" || enabled[1].Name != "gws" { + t.Errorf("enabled = %v", enabled) + } +} + +func TestEnabledProviders_NilConfig(t *testing.T) { + all := []Provider{{Name: "a"}, {Name: "b"}} + enabled := EnabledProviders(all, nil) + if len(enabled) != 2 { + t.Errorf("nil config should return all, got %d", len(enabled)) + } +} + +func TestMaskValue(t *testing.T) { + tests := []struct { + val string + show int + want string + }{ + {"super-secret-token", 4, "supe***"}, + {"abc", 4, "***"}, + {"", 4, "***"}, + {"abcdef", 4, "abcd***"}, + {"ab", 4, "***"}, + } + for _, tt := range tests { + got := MaskValue(tt.val, tt.show) + if got != tt.want { + t.Errorf("MaskValue(%q, %d) = %q, want %q", tt.val, tt.show, got, tt.want) + } + } +} + +func TestFileMetadata_ADC(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "adc.json") + os.WriteFile(path, []byte(`{"quota_project_id": "my-project", "type": "authorized_user"}`), 0o644) + + meta := FileMetadata(path) + if meta == nil { + t.Fatal("expected metadata") + } + if meta["project"] != "my-project" { + t.Errorf("project = %q", meta["project"]) + } + if meta["type"] != "authorized_user" { + t.Errorf("type = %q", meta["type"]) + } +} + +func TestFileMetadata_GWS(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client_secret.json") + os.WriteFile(path, []byte(`{"installed": {"client_id": "1715999888.apps.googleusercontent.com"}}`), 0o644) + + meta := FileMetadata(path) + if meta == nil { + t.Fatal("expected metadata") + } + if meta["client_id"] != "1715***" { + t.Errorf("client_id = %q, want masked", meta["client_id"]) + } +} + +func TestFileMetadata_NotJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plain.txt") + os.WriteFile(path, []byte("not json"), 0o644) + + meta := FileMetadata(path) + if meta != nil { + t.Errorf("expected nil for non-JSON, got %v", meta) + } +} + +func TestFileMetadata_Missing(t *testing.T) { + meta := FileMetadata("/nonexistent.json") + if meta != nil { + t.Errorf("expected nil for missing file, got %v", meta) + } +} + +func TestCheckInput_EnvSet(t *testing.T) { + t.Setenv("TEST_VAR", "hello") + ok, detail := CheckInput(Input{Key: "TEST_VAR", Kind: "env"}) + if !ok { + t.Error("expected ok") + } + if detail != "✓ local env: TEST_VAR=hello" { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_EnvMissing(t *testing.T) { + ok, detail := CheckInput(Input{Key: "NONEXISTENT_VAR_XYZ", Kind: "env"}) + if ok { + t.Error("expected not ok") + } + if detail != "✗ local env: NONEXISTENT_VAR_XYZ not set → export NONEXISTENT_VAR_XYZ=..." { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_EnvSecret(t *testing.T) { + t.Setenv("SECRET_VAR", "super-secret-value") + ok, detail := CheckInput(Input{Key: "SECRET_VAR", Kind: "env", Secret: true}) + if !ok { + t.Error("expected ok") + } + if detail != "✓ local env: SECRET_VAR=supe***" { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_FileExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + os.WriteFile(path, []byte("data"), 0o644) + + ok, detail := CheckInput(Input{Key: path, Kind: "file"}) + if !ok { + t.Error("expected ok") + } + if detail != "✓ local file: "+path { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_FileMissing(t *testing.T) { + ok, detail := CheckInput(Input{Key: "/nonexistent/file.json", Kind: "file"}) + if ok { + t.Error("expected not ok") + } + if detail != "✗ local file: /nonexistent/file.json not found" { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_CheckPass(t *testing.T) { + ok, detail := CheckInput(Input{Key: "true", Kind: "check"}) + if !ok { + t.Error("expected ok") + } + if detail != "✓ check: true" { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckInput_CheckFail(t *testing.T) { + ok, detail := CheckInput(Input{Key: "false", Kind: "check"}) + if ok { + t.Error("expected not ok") + } + if detail != "✗ check: false" { + t.Errorf("detail = %q", detail) + } +} + +func TestCheckProvider_AllPass(t *testing.T) { + t.Setenv("GOOD_VAR", "yes") + p := Provider{ + Name: "test", + Inputs: []Input{{Key: "GOOD_VAR", Kind: "env"}}, + } + ok, details := CheckProvider(p) + if !ok { + t.Error("expected ok") + } + if len(details) != 1 { + t.Errorf("details = %v", details) + } +} + +func TestCheckProvider_SomeFail(t *testing.T) { + t.Setenv("SET_VAR", "yes") + p := Provider{ + Name: "test", + Inputs: []Input{ + {Key: "SET_VAR", Kind: "env"}, + {Key: "MISSING_VAR_XYZ", Kind: "env"}, + }, + } + ok, details := CheckProvider(p) + if ok { + t.Error("expected not ok") + } + if len(details) != 2 { + t.Errorf("details = %v", details) + } +}