- Interactive deploy command with 8-step walkthrough: framework → provider → token → SSH → server → inference → tailscale → discord - .env file generation from walkthrough config - DeploymentConfig struct with framework-aware defaults - Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic - Hetzner Cloud provider: token validation, SSH key listing - DotEnv parser/writer with schema validation - Destroy command with confirmation prompt - Validation subcommand for checking existing .env files - All tests passing, go vet clean
547 lines
No EOL
14 KiB
Go
547 lines
No EOL
14 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mockProvider implements Validatable for testing.
|
|
type mockProvider struct {
|
|
name string
|
|
checks []Check
|
|
}
|
|
|
|
func (m *mockProvider) ProviderName() string { return m.name }
|
|
func (m *mockProvider) Checks(ctx context.Context) []Check { return m.checks }
|
|
|
|
// mockCheck implements Check for testing.
|
|
type mockCheck struct {
|
|
name string
|
|
category CheckCategory
|
|
result CheckResult
|
|
}
|
|
|
|
func (m *mockCheck) Name() string { return m.name }
|
|
func (m *mockCheck) Category() CheckCategory { return m.category }
|
|
func (m *mockCheck) Run(ctx context.Context) CheckResult { return m.result }
|
|
|
|
func TestCheckResultPassed(t *testing.T) {
|
|
tests := []struct {
|
|
status Status
|
|
expected bool
|
|
}{
|
|
{Pass, true},
|
|
{Warn, true},
|
|
{Fail, false},
|
|
{Skip, false},
|
|
{Error, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
r := CheckResult{Status: tc.status}
|
|
if r.Passed() != tc.expected {
|
|
t.Errorf("CheckResult{Status: %s}.Passed() = %v, want %v", tc.status, r.Passed(), tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckResultIcon(t *testing.T) {
|
|
tests := []struct {
|
|
status Status
|
|
expected string
|
|
}{
|
|
{Pass, "✓"},
|
|
{Fail, "✗"},
|
|
{Warn, "!"},
|
|
{Skip, "—"},
|
|
{Error, "⚠"},
|
|
{Status("unknown"), "?"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
r := CheckResult{Status: tc.status}
|
|
if r.Icon() != tc.expected {
|
|
t.Errorf("CheckResult{Status: %s}.Icon() = %q, want %q", tc.status, r.Icon(), tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckFunc(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
cf := CheckFunc{
|
|
CategoryField: CategoryCredentials,
|
|
NameField: "test-check",
|
|
RunFunc: func(ctx context.Context) CheckResult {
|
|
return CheckResult{
|
|
Name: "test-check",
|
|
Category: CategoryCredentials,
|
|
Status: Pass,
|
|
Message: "check passed",
|
|
}
|
|
},
|
|
}
|
|
|
|
if cf.Name() != "test-check" {
|
|
t.Errorf("CheckFunc.Name() = %q, want %q", cf.Name(), "test-check")
|
|
}
|
|
if cf.Category() != CategoryCredentials {
|
|
t.Errorf("CheckFunc.Category() = %q, want %q", cf.Category(), CategoryCredentials)
|
|
}
|
|
|
|
result := cf.Run(ctx)
|
|
if result.Status != Pass {
|
|
t.Errorf("CheckFunc.Run().Status = %v, want %v", result.Status, Pass)
|
|
}
|
|
}
|
|
|
|
func TestReportSummary(t *testing.T) {
|
|
report := &Report{
|
|
Provider: "TestProvider",
|
|
Results: []CheckResult{
|
|
{Status: Pass, Name: "check1"},
|
|
{Status: Pass, Name: "check2"},
|
|
{Status: Fail, Name: "check3"},
|
|
{Status: Warn, Name: "check4"},
|
|
{Status: Skip, Name: "check5"},
|
|
},
|
|
}
|
|
|
|
summary := report.Summary()
|
|
if summary[Pass] != 2 {
|
|
t.Errorf("Summary[Pass] = %d, want 2", summary[Pass])
|
|
}
|
|
if summary[Fail] != 1 {
|
|
t.Errorf("Summary[Fail] = %d, want 1", summary[Fail])
|
|
}
|
|
if summary[Warn] != 1 {
|
|
t.Errorf("Summary[Warn] = %d, want 1", summary[Warn])
|
|
}
|
|
if summary[Skip] != 1 {
|
|
t.Errorf("Summary[Skip] = %d, want 1", summary[Skip])
|
|
}
|
|
}
|
|
|
|
func TestReportHasFailures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
results []CheckResult
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "all_pass",
|
|
results: []CheckResult{{Status: Pass}, {Status: Pass}},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "one_fail",
|
|
results: []CheckResult{{Status: Pass}, {Status: Fail}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "one_error",
|
|
results: []CheckResult{{Status: Pass}, {Status: Pass}, {Status: Error}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "with_warn_and_skip",
|
|
results: []CheckResult{{Status: Pass}, {Status: Warn}, {Status: Skip}},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
report := &Report{Results: tc.results}
|
|
if report.HasFailures() != tc.expected {
|
|
t.Errorf("HasFailures() = %v, want %v", report.HasFailures(), tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportByCategory(t *testing.T) {
|
|
report := &Report{
|
|
Provider: "TestProvider",
|
|
Results: []CheckResult{
|
|
{Name: "token-auth", Category: CategoryCredentials, Status: Pass, Message: "token valid"},
|
|
{Name: "api-reach", Category: CategoryConnectivity, Status: Pass, Message: "API reachable"},
|
|
{Name: "ssh-keys", Category: CategorySSH, Status: Fail, Message: "no SSH keys"},
|
|
{Name: "token-format", Category: CategoryCredentials, Status: Pass, Message: "format OK"},
|
|
},
|
|
}
|
|
|
|
byCat := report.ByCategory()
|
|
|
|
if len(byCat[CategoryCredentials]) != 2 {
|
|
t.Errorf("ByCategory[Credentials] has %d items, want 2", len(byCat[CategoryCredentials]))
|
|
}
|
|
if len(byCat[CategorySSH]) != 1 {
|
|
t.Errorf("ByCategory[SSH] has %d items, want 1", len(byCat[CategorySSH]))
|
|
}
|
|
if len(byCat[CategoryQuota]) != 0 {
|
|
t.Errorf("ByCategory[Quota] has %d items, want 0", len(byCat[CategoryQuota]))
|
|
}
|
|
}
|
|
|
|
func TestReportFormat(t *testing.T) {
|
|
report := &Report{
|
|
Provider: "TestProvider",
|
|
Results: []CheckResult{
|
|
{Name: "token-auth", Category: CategoryCredentials, Status: Pass, Message: "Token authenticated"},
|
|
{Name: "ssh-keys", Category: CategorySSH, Status: Fail, Message: "No SSH keys configured"},
|
|
{Name: "regions", Category: CategoryServer, Status: Warn, Message: "Region not set, using default"},
|
|
},
|
|
TotalDuration: 150 * time.Millisecond,
|
|
}
|
|
|
|
output := report.Format()
|
|
|
|
// Verify key elements appear in output
|
|
if !containsAll(output, "TestProvider", "token-auth", "Token authenticated", "✓") {
|
|
t.Errorf("Format() missing expected output elements")
|
|
}
|
|
if !containsAll(output, "No SSH keys configured", "✗") {
|
|
t.Errorf("Format() missing FAIL elements")
|
|
}
|
|
if !containsAll(output, "Region not set", "!") {
|
|
t.Errorf("Format() missing WARN elements (icon '!')")
|
|
}
|
|
// Check for summary line
|
|
if !strings.Contains(output, "Total:") {
|
|
t.Errorf("Format() missing 'Total:' in output")
|
|
}
|
|
}
|
|
|
|
func containsAll(s string, substrs ...string) bool {
|
|
for _, sub := range substrs {
|
|
if !contains(s, sub) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || containsSubstring(s, sub))
|
|
}
|
|
|
|
func containsSubstring(s, sub string) bool {
|
|
for i := 0; i <= len(s)-len(sub); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestRunner(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
provider := &mockProvider{
|
|
name: "MockProvider",
|
|
checks: []Check{
|
|
&mockCheck{
|
|
name: "token-format",
|
|
category: CategoryCredentials,
|
|
result: CheckResult{Status: Pass, Message: "Token format valid"},
|
|
},
|
|
&mockCheck{
|
|
name: "token-auth",
|
|
category: CategoryCredentials,
|
|
result: CheckResult{Status: Pass, Message: "Token authenticated"},
|
|
},
|
|
&mockCheck{
|
|
name: "ssh-keys",
|
|
category: CategorySSH,
|
|
result: CheckResult{Status: Fail, Message: "No SSH keys found"},
|
|
},
|
|
},
|
|
}
|
|
|
|
runner := NewRunner(provider)
|
|
report := runner.Run(ctx)
|
|
|
|
if report.Provider != "MockProvider" {
|
|
t.Errorf("Report.Provider = %q, want %q", report.Provider, "MockProvider")
|
|
}
|
|
|
|
if len(report.Results) != 3 {
|
|
t.Errorf("Report has %d results, want 3", len(report.Results))
|
|
}
|
|
|
|
if !report.HasFailures() {
|
|
t.Error("Report.HasFailures() = false, expected true (one check failed)")
|
|
}
|
|
|
|
// Verify check names are set on results
|
|
for i, r := range report.Results {
|
|
if r.Name == "" {
|
|
t.Errorf("Result[%d].Name is empty, should be set from Check", i)
|
|
}
|
|
if r.Category == "" {
|
|
t.Errorf("Result[%d].Category is empty, should be set from Check", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunnerTimeout(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Check that times out
|
|
timeoutCheck := &mockCheck{
|
|
name: "slow-check",
|
|
category: CategoryConnectivity,
|
|
result: CheckResult{Status: Error, Message: "context deadline exceeded"},
|
|
}
|
|
|
|
provider := &mockProvider{
|
|
name: "TimeoutProvider",
|
|
checks: []Check{timeoutCheck},
|
|
}
|
|
|
|
runner := NewRunner(provider)
|
|
runner.SetTimeout(50 * time.Millisecond)
|
|
|
|
report := runner.Run(ctx)
|
|
|
|
if len(report.Results) != 1 {
|
|
t.Fatalf("Expected 1 result, got %d", len(report.Results))
|
|
}
|
|
|
|
if report.Results[0].Status != Error {
|
|
t.Errorf("Expected Error status, got %s", report.Results[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestRunnerEmptyChecks(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
provider := &mockProvider{
|
|
name: "EmptyProvider",
|
|
checks: []Check{},
|
|
}
|
|
|
|
runner := NewRunner(provider)
|
|
report := runner.Run(ctx)
|
|
|
|
if len(report.Results) != 0 {
|
|
t.Errorf("Expected 0 results, got %d", len(report.Results))
|
|
}
|
|
|
|
if report.HasFailures() {
|
|
t.Error("Empty report should not have failures")
|
|
}
|
|
}
|
|
|
|
func TestContextCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
// A check that would fail if context wasn't properly handled
|
|
check := CheckFunc{
|
|
NameField: "canceled-check",
|
|
CategoryField: CategoryCredentials,
|
|
RunFunc: func(ctx context.Context) CheckResult {
|
|
select {
|
|
case <-ctx.Done():
|
|
return CheckResult{Status: Error, Message: "context canceled"}
|
|
default:
|
|
return CheckResult{Status: Pass, Message: "check passed"}
|
|
}
|
|
},
|
|
}
|
|
|
|
provider := &mockProvider{
|
|
name: "CanceledProvider",
|
|
checks: []Check{&check},
|
|
}
|
|
|
|
runner := NewRunner(provider)
|
|
report := runner.Run(ctx)
|
|
|
|
if len(report.Results) != 1 {
|
|
t.Fatalf("Expected 1 result, got %d", len(report.Results))
|
|
}
|
|
|
|
// The result depends on when the check runs — if after cancellation, it's Error
|
|
// This test verifies the context is properly propagated
|
|
if report.Results[0].Name != "canceled-check" {
|
|
t.Errorf("Expected name 'canceled-check', got %q", report.Results[0].Name)
|
|
}
|
|
}
|
|
|
|
func TestReportAllPassed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
results []CheckResult
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "all_pass",
|
|
results: []CheckResult{{Status: Pass}, {Status: Pass}, {Status: Pass}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "pass_with_warns",
|
|
results: []CheckResult{{Status: Pass}, {Status: Warn}, {Status: Pass}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "pass_with_skips",
|
|
results: []CheckResult{{Status: Pass}, {Status: Skip}, {Status: Pass}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "pass_with_one_fail",
|
|
results: []CheckResult{{Status: Pass}, {Status: Fail}, {Status: Pass}},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "pass_with_one_error",
|
|
results: []CheckResult{{Status: Pass}, {Status: Error}, {Status: Pass}},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
report := &Report{Results: tc.results}
|
|
if report.AllPassed() != tc.expected {
|
|
t.Errorf("AllPassed() = %v, want %v", report.AllPassed(), tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ExampleCheck demonstrates creating a custom check.
|
|
func ExampleCheckFunc() {
|
|
ctx := context.Background()
|
|
|
|
tokenFormatCheck := CheckFunc{
|
|
CategoryField: CategoryCredentials,
|
|
NameField: "token-format",
|
|
RunFunc: func(ctx context.Context) CheckResult {
|
|
// In a real check, you'd validate the token format here
|
|
return CheckResult{
|
|
Name: "token-format",
|
|
Category: CategoryCredentials,
|
|
Status: Pass,
|
|
Message: "Token format is valid",
|
|
Detail: "32 characters, alphanumeric",
|
|
}
|
|
},
|
|
}
|
|
|
|
// Run the check
|
|
result := tokenFormatCheck.Run(ctx)
|
|
fmt.Printf("%s: %s\n", result.Status, result.Message)
|
|
// Output: PASS: Token format is valid
|
|
}
|
|
|
|
// Integration test: full runner with realistic mock
|
|
func TestRunnerIntegration(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Simulate a realistic provider with multiple check categories
|
|
provider := &mockProvider{
|
|
name: "Hetzner Cloud",
|
|
checks: []Check{
|
|
// Credentials
|
|
&mockCheck{
|
|
name: "token-format",
|
|
category: CategoryCredentials,
|
|
result: CheckResult{Status: Pass, Message: "Token format valid"},
|
|
},
|
|
&mockCheck{
|
|
name: "token-auth",
|
|
category: CategoryCredentials,
|
|
result: CheckResult{Status: Pass, Message: "Token authenticated", Detail: "Account: acme-corp"},
|
|
},
|
|
// Connectivity
|
|
&mockCheck{
|
|
name: "api-reachability",
|
|
category: CategoryConnectivity,
|
|
result: CheckResult{Status: Pass, Message: "API endpoint reachable", Detail: "Latency: 45ms"},
|
|
},
|
|
// SSH Keys
|
|
&mockCheck{
|
|
name: "ssh-keys",
|
|
category: CategorySSH,
|
|
result: CheckResult{Status: Fail, Message: "No SSH keys registered in account"},
|
|
},
|
|
// Server Config
|
|
&mockCheck{
|
|
name: "location",
|
|
category: CategoryServer,
|
|
result: CheckResult{Status: Pass, Message: "Location 'fsn1' is valid"},
|
|
},
|
|
&mockCheck{
|
|
name: "server-type",
|
|
category: CategoryServer,
|
|
result: CheckResult{Status: Pass, Message: "Server type 'cpx21' is available"},
|
|
},
|
|
// Quota
|
|
&mockCheck{
|
|
name: "server-quota",
|
|
category: CategoryQuota,
|
|
result: CheckResult{Status: Warn, Message: "Near quota limit", Detail: "48/50 servers used"},
|
|
},
|
|
},
|
|
}
|
|
|
|
runner := NewRunner(provider)
|
|
report := runner.Run(ctx)
|
|
|
|
// Verify
|
|
if report.Provider != "Hetzner Cloud" {
|
|
t.Errorf("Provider name = %q, want 'Hetzner Cloud'", report.Provider)
|
|
}
|
|
|
|
if len(report.Results) != 7 {
|
|
t.Errorf("Expected 7 checks, got %d", len(report.Results))
|
|
}
|
|
|
|
if !report.HasFailures() {
|
|
t.Error("Expected failures (SSH check should fail)")
|
|
}
|
|
|
|
// Check each category is represented
|
|
cats := make(map[CheckCategory]bool)
|
|
for _, r := range report.Results {
|
|
cats[r.Category] = true
|
|
}
|
|
|
|
expectedCats := []CheckCategory{
|
|
CategoryCredentials, CategoryConnectivity, CategorySSH,
|
|
CategoryServer, CategoryQuota,
|
|
}
|
|
for _, cat := range expectedCats {
|
|
if !cats[cat] {
|
|
t.Errorf("Missing category: %s", cat)
|
|
}
|
|
}
|
|
|
|
// Verify output is not empty
|
|
output := report.Format()
|
|
if len(output) == 0 {
|
|
t.Error("Format() produced empty output")
|
|
}
|
|
|
|
// Verify specific elements in output
|
|
containsCheck(t, output, "Hetzner Cloud")
|
|
containsCheck(t, output, "token-auth")
|
|
containsCheck(t, output, "No SSH keys")
|
|
containsCheck(t, output, "Near quota")
|
|
containsCheck(t, output, "FAIL")
|
|
}
|
|
|
|
func containsCheck(t *testing.T, s, substr string) {
|
|
t.Helper()
|
|
if !strings.Contains(s, substr) {
|
|
t.Errorf("Expected substring %q not found in output", substr)
|
|
}
|
|
} |