diff --git a/CLAUDE.md b/CLAUDE.md index f8e8b47..af3cf19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,8 +132,12 @@ make push S3_BUCKET= VERSION= # sync dist/ to S3 (version + late make release S3_BUCKET= VERSION= # per-binary copy with OS/arch naming ``` -There is no test suite. Verify changes by running `go build ./...` and exercising the -TUI manually. Requires a working Docker daemon and registry access to fully run. +Tests (`go test ./...`) cover the pure logic in +[config_test.go](internal/tui/config_test.go) (config.toml/envs generation + numeric +validation) and [form_test.go](internal/tui/form_test.go) (`FormStep.Values()`). The +docker wrappers, Update and View are not unit-tested — verify those by running +`go build ./...` and exercising the TUI manually. Fully running the installer requires +a working Docker daemon and registry access. ## Conventions diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go new file mode 100644 index 0000000..f49c049 --- /dev/null +++ b/internal/tui/config_test.go @@ -0,0 +1,247 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +// fullConfigValues returns a ConfigValues with every field populated with valid data. +func fullConfigValues() ConfigValues { + return ConfigValues{ + Login: map[string]string{"user": "u", "password": "p"}, + Server: map[string]string{ + "port": "9090", + "timeout": "45", + "environment": "production", + "seccomp_unconfined": "Não", + }, + Database: map[string]string{ + "database_type": "postgres", + "database_url": "postgres://user:pass@db:5432/app_dono_db", + "max_conns": "20", + "min_conns": "5", + }, + Cert: map[string]string{"cert_dir_path": "/etc/app-dono/certs"}, + Application: map[string]string{ + "central_server_url": "https://central.example.com:8443", + "enrollment_token": "tok-123", + }, + Wireguard: map[string]string{ + "privkey": "PRIVKEY_VALUE", + "vip": "127.0.0.1", + "psk": "PSK_VALUE", + "proxy_edps": "22:127.0.0.1:22", + "mtu": "1380", + "proto": "UDP", + }, + } +} + +func TestGenerateConfigTOML_RoundTrip(t *testing.T) { + cv := fullConfigValues() + + out, err := GenerateConfigTOML(cv) + if err != nil { + t.Fatalf("GenerateConfigTOML returned error: %v", err) + } + + var cfg AppConfig + if _, err := toml.Decode(out, &cfg); err != nil { + t.Fatalf("generated output is not valid TOML / failed to decode: %v\n---\n%s", err, out) + } + + if cfg.Server.Timeout != 45 { + t.Errorf("timeout_seconds = %d, want 45", cfg.Server.Timeout) + } + if cfg.Server.Environment != "production" { + t.Errorf("environment = %q, want %q", cfg.Server.Environment, "production") + } + if cfg.Database.Type != "postgres" { + t.Errorf("database type = %q, want %q", cfg.Database.Type, "postgres") + } + if cfg.Database.URL != "postgres://user:pass@db:5432/app_dono_db" { + t.Errorf("database url = %q", cfg.Database.URL) + } + if cfg.Database.MaxConns != 20 { + t.Errorf("max_conns = %d, want 20", cfg.Database.MaxConns) + } + if cfg.Database.MinConns != 5 { + t.Errorf("min_conns = %d, want 5", cfg.Database.MinConns) + } + if cfg.Certificates.DirPath != "/etc/app-dono/certs" { + t.Errorf("mapped_dir = %q", cfg.Certificates.DirPath) + } + if cfg.Application.CentralServerURL != "https://central.example.com:8443" { + t.Errorf("central_server_url = %q", cfg.Application.CentralServerURL) + } + if cfg.Application.EnrollmentToken != "tok-123" { + t.Errorf("enrollment_token = %q", cfg.Application.EnrollmentToken) + } +} + +// The container always listens on containerAppPort internally; the user-provided +// "Porta (host)" value is only the host-side mapping and must never leak into the +// generated config.toml. This guard fails loudly if that invariant is reverted. +func TestGenerateConfigTOML_PortIsAlwaysContainerPort(t *testing.T) { + cv := fullConfigValues() + cv.Server["port"] = "9090" // host port, must NOT appear as the config port + + out, err := GenerateConfigTOML(cv) + if err != nil { + t.Fatalf("GenerateConfigTOML returned error: %v", err) + } + + var cfg AppConfig + if _, err := toml.Decode(out, &cfg); err != nil { + t.Fatalf("failed to decode: %v", err) + } + + if cfg.Server.Port != containerAppPort { + t.Errorf("config port = %d, want fixed containerAppPort %d", cfg.Server.Port, containerAppPort) + } + if strings.Contains(out, "port = 9090") { + t.Errorf("host port 9090 leaked into config.toml:\n%s", out) + } +} + +func TestGenerateWireguardConfig(t *testing.T) { + tests := []struct { + name string + wireguard map[string]string + wantContains []string + wantNotContns []string + }{ + { + name: "all fields set", + wireguard: map[string]string{ + "privkey": "PK", "vip": "10.0.0.1", "psk": "PSK", + "proxy_edps": "22:127.0.0.1:22", "mtu": "1400", "proto": "TCP", + }, + wantContains: []string{ + "PRIVKEY=PK", "VIP=10.0.0.1", "PSK=PSK", + "PROXY_EDPS=22:127.0.0.1:22", "MTU=1400", "PROTO=TCP", + }, + }, + { + name: "empty mtu is commented out", + wireguard: map[string]string{ + "privkey": "PK", "vip": "10.0.0.1", "psk": "PSK", + "proxy_edps": "22:127.0.0.1:22", "mtu": "", "proto": "UDP", + }, + wantContains: []string{"# MTU=1380", "PROTO=UDP"}, + wantNotContns: []string{"\nMTU="}, + }, + { + name: "empty proto defaults to UDP", + wireguard: map[string]string{ + "privkey": "PK", "vip": "10.0.0.1", "psk": "PSK", + "proxy_edps": "22:127.0.0.1:22", "proto": "", + }, + wantContains: []string{"PROTO=UDP"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := GenerateWireguardConfig(ConfigValues{Wireguard: tt.wireguard}) + if err != nil { + t.Fatalf("GenerateWireguardConfig returned error: %v", err) + } + for _, want := range tt.wantContains { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + for _, notWant := range tt.wantNotContns { + if strings.Contains(out, notWant) { + t.Errorf("output should not contain %q:\n%s", notWant, out) + } + } + }) + } +} + +func TestWriteConfigFile_NumericValidation(t *testing.T) { + numeric := []string{"port", "timeout", "max_conns", "min_conns"} + + for _, field := range numeric { + t.Run("invalid_"+field, func(t *testing.T) { + cv := fullConfigValues() + switch field { + case "port", "timeout": + cv.Server[field] = "abc" + case "max_conns", "min_conns": + cv.Database[field] = "abc" + } + + path := filepath.Join(t.TempDir(), "config.toml") + err := WriteConfigFile(cv, path) + if err == nil { + t.Fatalf("expected error for non-numeric %q, got nil", field) + } + if !strings.Contains(err.Error(), field) { + t.Errorf("error %q does not mention field %q", err.Error(), field) + } + if _, statErr := os.Stat(path); statErr == nil { + t.Errorf("file should not have been written on validation failure") + } + }) + } +} + +func TestWriteConfigFile_ValidWritesFile(t *testing.T) { + cv := fullConfigValues() + path := filepath.Join(t.TempDir(), "config.toml") + + if err := WriteConfigFile(cv, path); err != nil { + t.Fatalf("WriteConfigFile returned error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("config file was not written: %v", err) + } + + var cfg AppConfig + if _, err := toml.Decode(string(data), &cfg); err != nil { + t.Fatalf("written file is not valid TOML: %v", err) + } +} + +func TestWriteWireguardConfigFile_MTUValidation(t *testing.T) { + tests := []struct { + name string + mtu string + wantErr bool + }{ + {name: "valid mtu", mtu: "1380", wantErr: false}, + {name: "empty mtu allowed", mtu: "", wantErr: false}, + {name: "non-numeric mtu rejected", mtu: "big", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cv := fullConfigValues() + cv.Wireguard["mtu"] = tt.mtu + + path := filepath.Join(t.TempDir(), "envs") + err := WriteWireguardConfigFile(cv, path) + + if tt.wantErr && err == nil { + t.Fatalf("expected error for mtu %q, got nil", tt.mtu) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for mtu %q: %v", tt.mtu, err) + } + if !tt.wantErr { + if _, statErr := os.Stat(path); statErr != nil { + t.Errorf("envs file should have been written: %v", statErr) + } + } + }) + } +} diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go new file mode 100644 index 0000000..76602e1 --- /dev/null +++ b/internal/tui/form_test.go @@ -0,0 +1,64 @@ +package tui + +import "testing" + +func TestFormStepValues(t *testing.T) { + f := NewFormStep("Test", []FormField{ + { + Id: "name", + Label: "Name", + Default: "john", + Type: FieldTypeText, + }, + { + Id: "secret", + Label: "Secret", + Default: "hunter2", + Type: FieldTypePassword, + }, + { + Id: "proto", + Label: "Protocol", + Default: "TCP", + Type: FieldTypeSelect, + Options: []string{"UDP", "TCP"}, + }, + { + Id: "mode", + Label: "Mode", + Type: FieldTypeSelect, + Options: []string{"a", "b", "c"}, + }, + }) + + values := f.Values() + + want := map[string]string{ + "name": "john", // text default + "secret": "hunter2", + "proto": "TCP", // select default resolves to the matching option + "mode": "a", // select with no default falls back to first option + } + + for id, exp := range want { + if got := values[id]; got != exp { + t.Errorf("Values()[%q] = %q, want %q", id, got, exp) + } + } +} + +func TestFormStepValues_SelectInvalidDefaultFallsBackToFirst(t *testing.T) { + f := NewFormStep("Test", []FormField{ + { + Id: "proto", + Label: "Protocol", + Default: "NOPE", // not among options + Type: FieldTypeSelect, + Options: []string{"UDP", "TCP"}, + }, + }) + + if got := f.Values()["proto"]; got != "UDP" { + t.Errorf("Values()[proto] = %q, want first option %q", got, "UDP") + } +}